Browse Source

ref(debug-files-settings): Add settings dialog for appstore connect (#25993)

Priscila Oliveira 3 years ago
parent
commit
a7fa75705f

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

@@ -184,10 +184,12 @@ type DebugFileSourceModalOptions = {
 };
 
 export async function openDebugFileSourceModal(options: DebugFileSourceModalOptions) {
-  const mod = await import('app/components/modals/debugFileSourceModal');
-  const {default: Modal} = mod;
+  const mod = await import(
+    /* webpackChunkName: "DebugFileCustomRepository" */ 'app/components/modals/debugFileCustomRepository'
+  );
+  const {default: Modal, modalCss} = mod;
 
-  openModal(deps => <Modal {...deps} {...options} />);
+  openModal(deps => <Modal {...deps} {...options} />, {backdrop: 'static', modalCss});
 }
 
 export async function openInviteMembersModal(options = {}) {

+ 12 - 12
static/app/components/alert.tsx

@@ -22,6 +22,16 @@ type AlertThemeProps = {
 
 const DEFAULT_TYPE = 'info';
 
+const IconWrapper = styled('span')`
+  display: flex;
+  margin-right: ${space(1)};
+
+  /* Give the wrapper an explicit height so icons are line height with the
+   * (common) line height. */
+  height: 22px;
+  align-items: center;
+`;
+
 const getAlertColorStyles = ({
   backgroundLight,
   border,
@@ -29,7 +39,7 @@ const getAlertColorStyles = ({
 }: AlertThemeProps) => css`
   background: ${backgroundLight};
   border: 1px solid ${border};
-  svg {
+  ${IconWrapper} {
     color: ${iconColor};
   }
 `;
@@ -43,7 +53,7 @@ const getSystemAlertColorStyles = ({
   border: 0;
   border-radius: 0;
   border-bottom: 1px solid ${border};
-  svg {
+  ${IconWrapper} {
     color: ${iconColor};
   }
 `;
@@ -67,16 +77,6 @@ const alertStyles = ({theme, type = DEFAULT_TYPE, system}: Props & {theme: Theme
   ${system && getSystemAlertColorStyles(theme.alert[type])};
 `;
 
-const IconWrapper = styled('span')`
-  display: flex;
-  margin-right: ${space(1)};
-
-  /* Give the wrapper an explicit height so icons are line height with the
-   * (common) line height. */
-  height: 22px;
-  align-items: center;
-`;
-
 const StyledTextBlock = styled('span')`
   line-height: 1.5;
   flex-grow: 1;

+ 1 - 1
static/app/components/badge.tsx

@@ -24,7 +24,7 @@ const Badge = styled(({children, text, ...props}: Props) => (
   font-size: 75%;
   font-weight: 600;
   text-align: center;
-  color: #fff;
+  color: ${p => p.theme.badge[p.type ?? 'default'].color};
   background: ${p => p.theme.badge[p.type ?? 'default'].background};
   transition: background 100ms linear;
 

+ 233 - 0
static/app/components/modals/debugFileCustomRepository/appStoreConnect/appStoreCredentials/form.tsx

@@ -0,0 +1,233 @@
+import {Fragment, useEffect, useState} from 'react';
+import styled from '@emotion/styled';
+
+import {addErrorMessage} from 'app/actionCreators/indicator';
+import {Client} from 'app/api';
+import Alert from 'app/components/alert';
+import {IconWarning} from 'app/icons';
+import {t} from 'app/locale';
+import {Organization, Project} from 'app/types';
+import Input from 'app/views/settings/components/forms/controls/input';
+import Textarea from 'app/views/settings/components/forms/controls/textarea';
+import Field from 'app/views/settings/components/forms/field';
+import SelectField from 'app/views/settings/components/forms/selectField';
+
+import Stepper from '../stepper';
+import StepActions from '../stepper/stepActions';
+import {
+  App,
+  AppStoreCredentialsData,
+  AppStoreCredentialsStepOneData,
+  AppStoreCredentialsStepTwoData,
+} from '../types';
+
+const steps = [t('Enter your credentials'), t('Choose an application')];
+
+type Props = {
+  api: Client;
+  orgSlug: Organization['slug'];
+  projectSlug: Project['slug'];
+  data: AppStoreCredentialsData;
+  onChange: (data: AppStoreCredentialsData) => void;
+  onCancel?: () => void;
+};
+
+function Form({api, orgSlug, projectSlug, data, onChange, onCancel}: Props) {
+  const [activeStep, setActiveStep] = useState(0);
+  const [isLoading, setIsLoading] = useState(false);
+
+  const [appStoreApps, setAppStoreApps] = useState<App[]>([]);
+
+  const [stepOneData, setStepOneData] = useState<AppStoreCredentialsStepOneData>({
+    issuer: data.issuer,
+    keyId: data.keyId,
+    privateKey: data.privateKey,
+  });
+
+  const [stepTwoData, setStepTwoData] = useState<AppStoreCredentialsStepTwoData>({
+    app: data.app,
+  });
+
+  useEffect(() => {
+    onChange({
+      ...stepOneData,
+      ...stepTwoData,
+    });
+  }, [stepTwoData]);
+
+  function isFormInvalid() {
+    switch (activeStep) {
+      case 0:
+        return Object.keys(stepOneData).some(key => !stepOneData[key]?.trim());
+      case 1:
+        return Object.keys(stepTwoData).some(key => !stepTwoData[key]);
+      default:
+        return false;
+    }
+  }
+
+  function goNext() {
+    setActiveStep(prevActiveStep => prevActiveStep + 1);
+  }
+
+  function handleGoBack() {
+    setActiveStep(prevActiveStep => prevActiveStep - 1);
+  }
+
+  function handleGoNext() {
+    checkAppStoreConnectCredentials();
+  }
+
+  async function checkAppStoreConnectCredentials() {
+    setIsLoading(true);
+    try {
+      const response = await api.requestPromise(
+        `/projects/${orgSlug}/${projectSlug}/appstoreconnect/apps/`,
+        {
+          method: 'POST',
+          data: {
+            appconnectIssuer: stepOneData.issuer,
+            appconnectKey: stepOneData.keyId,
+            appconnectPrivateKey: stepOneData.privateKey,
+          },
+        }
+      );
+
+      setAppStoreApps(response.apps);
+      setStepTwoData({app: response.apps[0]});
+      setIsLoading(false);
+      goNext();
+    } catch {
+      setIsLoading(false);
+      addErrorMessage(
+        t(
+          'We could not establish a connection with App Store Connect. Please check the entered App Store Connect credentials.'
+        )
+      );
+    }
+  }
+
+  function renderStepContent(stepIndex: number) {
+    switch (stepIndex) {
+      case 0:
+        return (
+          <Fragment>
+            <Field
+              label={t('Issuer')}
+              inline={false}
+              flexibleControlStateSize
+              stacked
+              required
+            >
+              <Input
+                type="text"
+                name="issuer"
+                placeholder={t('Issuer')}
+                value={stepOneData.issuer}
+                onChange={e =>
+                  setStepOneData({
+                    ...stepOneData,
+                    issuer: e.target.value,
+                  })
+                }
+              />
+            </Field>
+            <Field
+              label={t('Key ID')}
+              inline={false}
+              flexibleControlStateSize
+              stacked
+              required
+            >
+              <Input
+                type="text"
+                name="keyId"
+                placeholder={t('Key Id')}
+                value={stepOneData.keyId}
+                onChange={e =>
+                  setStepOneData({
+                    ...stepOneData,
+                    keyId: e.target.value,
+                  })
+                }
+              />
+            </Field>
+            <Field
+              label={t('Private Key')}
+              inline={false}
+              flexibleControlStateSize
+              stacked
+              required
+            >
+              <Textarea
+                name="privateKey"
+                placeholder={t('Private Key')}
+                value={stepOneData.privateKey}
+                rows={5}
+                maxRows={5}
+                autosize
+                onChange={e =>
+                  setStepOneData({
+                    ...stepOneData,
+                    privateKey: e.target.value,
+                  })
+                }
+              />
+            </Field>
+          </Fragment>
+        );
+      case 1:
+        return (
+          <StyledSelectField
+            name="application"
+            label={t('App Store Connect Application')}
+            choices={appStoreApps.map(appStoreApp => [
+              appStoreApp.appId,
+              appStoreApp.name,
+            ])}
+            placeholder={t('Select application')}
+            onChange={appId => {
+              const selectedAppStoreApp = appStoreApps.find(
+                appStoreApp => appStoreApp.appId === appId
+              );
+              setStepTwoData({app: selectedAppStoreApp});
+            }}
+            value={stepTwoData.app?.appId ?? ''}
+            inline={false}
+            flexibleControlStateSize
+            stacked
+            required
+          />
+        );
+      default:
+        return (
+          <Alert type="error" icon={<IconWarning />}>
+            {t('This step could not be found.')}
+          </Alert>
+        );
+    }
+  }
+
+  return (
+    <Stepper
+      activeStep={activeStep}
+      steps={steps}
+      renderStepContent={index => renderStepContent(index)}
+      renderStepActions={index => (
+        <StepActions
+          onGoBack={index !== 0 ? handleGoBack : undefined}
+          onGoNext={index !== steps.length - 1 ? handleGoNext : undefined}
+          onCancel={onCancel}
+          goNextDisabled={isFormInvalid() || isLoading}
+          isLoading={isLoading}
+        />
+      )}
+    />
+  );
+}
+
+export default Form;
+
+const StyledSelectField = styled(SelectField)`
+  padding-right: 0;
+`;

+ 36 - 0
static/app/components/modals/debugFileCustomRepository/appStoreConnect/appStoreCredentials/index.tsx

@@ -0,0 +1,36 @@
+import {useState} from 'react';
+
+import {Client} from 'app/api';
+import {Organization, Project} from 'app/types';
+
+import Card from '../card';
+import {AppStoreCredentialsData} from '../types';
+
+import Form from './form';
+
+type Props = {
+  api: Client;
+  orgSlug: Organization['slug'];
+  projectSlug: Project['slug'];
+  isUpdating: boolean;
+  data: AppStoreCredentialsData;
+  onChange: (data: AppStoreCredentialsData) => void;
+};
+
+function AppStoreCredentials({data, isUpdating, ...props}: Props) {
+  const [isEditing, setIsEditing] = useState(!isUpdating);
+
+  if (isEditing) {
+    return (
+      <Form
+        {...props}
+        data={data}
+        onCancel={isUpdating ? () => setIsEditing(false) : undefined}
+      />
+    );
+  }
+
+  return <Card data={data} onDelete={() => setIsEditing(true)} />;
+}
+
+export default AppStoreCredentials;

+ 68 - 0
static/app/components/modals/debugFileCustomRepository/appStoreConnect/card.tsx

@@ -0,0 +1,68 @@
+import styled from '@emotion/styled';
+import capitalize from 'lodash/capitalize';
+
+import Button from 'app/components/button';
+import {IconEdit, IconLock} from 'app/icons';
+import {t} from 'app/locale';
+import space from 'app/styles/space';
+
+type Props = {
+  data: Record<string, any>;
+  onDelete: () => void;
+};
+
+function Card({data, onDelete}: Props) {
+  return (
+    <Wrapper>
+      <IconWrapper>
+        <IconLock size="lg" />
+      </IconWrapper>
+      <Content>
+        {Object.entries(data).map(([key, value]) => {
+          if (!value) {
+            return undefined;
+          }
+
+          const label = key
+            .split(/(?<=[a-z])(?=[A-Z])/)
+            .map(splittedKey => capitalize(splittedKey))
+            .join(' ');
+
+          return (
+            <ContentItem key={key}>
+              <strong>{`${label}:`}</strong>
+              <span>{value}</span>
+            </ContentItem>
+          );
+        })}
+      </Content>
+      <div>
+        <Button icon={<IconEdit />} label={t('Edit')} size="small" onClick={onDelete} />
+      </div>
+    </Wrapper>
+  );
+}
+
+export default Card;
+
+const Wrapper = styled('div')`
+  display: grid;
+  grid-template-columns: max-content 1fr max-content;
+  grid-gap: ${space(1)};
+`;
+
+const Content = styled('div')`
+  display: flex;
+  justify-content: center;
+  flex-direction: column;
+`;
+
+const IconWrapper = styled('div')`
+  display: flex;
+  align-items: center;
+  padding: 0 ${space(1.5)};
+`;
+
+const ContentItem = styled(Wrapper)`
+  font-size: ${p => p.theme.fontSizeMedium};
+`;

+ 219 - 0
static/app/components/modals/debugFileCustomRepository/appStoreConnect/index.tsx

@@ -0,0 +1,219 @@
+import {Fragment, useContext, useState} from 'react';
+import styled from '@emotion/styled';
+
+import {addErrorMessage, addSuccessMessage} from 'app/actionCreators/indicator';
+import {ModalRenderProps} from 'app/actionCreators/modal';
+import {Client} from 'app/api';
+import Alert from 'app/components/alert';
+import Button from 'app/components/button';
+import ButtonBar from 'app/components/buttonBar';
+import List from 'app/components/list';
+import ListItem from 'app/components/list/listItem';
+import {Panel} from 'app/components/panels';
+import {IconWarning} from 'app/icons';
+import {t} from 'app/locale';
+import space from 'app/styles/space';
+import {Organization, Project} from 'app/types';
+import withApi from 'app/utils/withApi';
+import AppStoreConnectContext from 'app/views/settings/project/appStoreConnectContext';
+
+import AppStoreCredentials from './appStoreCredentials';
+import ItunesCredentials from './itunesCredentials';
+import {AppStoreCredentialsData, ItunesCredentialsData} from './types';
+
+type IntialData = {
+  appId: string;
+  appName: string;
+  appconnectIssuer: string;
+  appconnectKey: string;
+  encrypted: string;
+  id: string;
+  itunesUser: string;
+  name: string;
+  orgId: number;
+  orgName: string;
+  type: string;
+};
+
+type Props = Pick<ModalRenderProps, 'Body' | 'Footer' | 'closeModal'> & {
+  api: Client;
+  orgSlug: Organization['slug'];
+  projectSlug: Project['slug'];
+  onSubmit: (data: Record<string, any>) => void;
+  initialData?: IntialData;
+};
+
+function AppStoreConnect({
+  Body,
+  Footer,
+  closeModal,
+  api,
+  initialData,
+  orgSlug,
+  projectSlug,
+  onSubmit,
+}: Props) {
+  const appStoreConnenctContext = useContext(AppStoreConnectContext);
+
+  const [isLoading, setIsLoading] = useState(false);
+
+  const [
+    appStoreCredentialsData,
+    setAppStoreCredentialsData,
+  ] = useState<AppStoreCredentialsData>({
+    issuer: initialData?.appconnectIssuer,
+    keyId: initialData?.appconnectKey,
+    privateKey: undefined,
+    app: undefined,
+  });
+
+  const [
+    itunesCredentialsData,
+    setItunesCredentialsData,
+  ] = useState<ItunesCredentialsData>({
+    username: initialData?.itunesUser,
+    password: undefined,
+    authenticationCode: undefined,
+    org: undefined,
+    useSms: undefined,
+    sessionContext: undefined,
+  });
+
+  async function handleSave() {
+    if (!itunesCredentialsData.org || !appStoreCredentialsData.app) {
+      return;
+    }
+
+    setIsLoading(true);
+    try {
+      const response = await api.requestPromise(
+        `/projects/${orgSlug}/${projectSlug}/appstoreconnect/`,
+        {
+          method: 'POST',
+          data: {
+            appconnectIssuer: appStoreCredentialsData.issuer,
+            appconnectKey: appStoreCredentialsData.keyId,
+            appconnectPrivateKey: appStoreCredentialsData.privateKey,
+            appName: appStoreCredentialsData.app.name,
+            appId: appStoreCredentialsData.app.appId,
+            itunesUser: itunesCredentialsData.username,
+            itunesPassword: itunesCredentialsData.password,
+            orgId: itunesCredentialsData.org.organizationId,
+            orgName: itunesCredentialsData.org.name,
+            sessionContext: itunesCredentialsData.sessionContext,
+          },
+        }
+      );
+      addSuccessMessage('App Store Connect repository was successfully added');
+      onSubmit(response);
+      closeModal();
+    } catch {
+      setIsLoading(false);
+      addErrorMessage(t('An error occured while saving the repository'));
+    }
+  }
+
+  function isFormInvalid() {
+    const generalData = {...appStoreCredentialsData, ...itunesCredentialsData};
+    return Object.keys(generalData).some(key => {
+      const value = generalData[key];
+
+      if (typeof value === 'string') {
+        return !value.trim();
+      }
+
+      return typeof value === 'undefined';
+    });
+  }
+
+  const isUpdating = !!initialData;
+
+  return (
+    <Fragment>
+      <Body>
+        <StyledList symbol="colored-numeric">
+          <ListItem>
+            <ItemTitle>{t('App Store Connect credentials')}</ItemTitle>
+            {!!appStoreConnenctContext?.appstoreCredentialsValid && (
+              <StyledAlert type="warning" icon={<IconWarning />}>
+                {t(
+                  'Your App Store Connect credentials are invalid. To reconnect, update your credentials'
+                )}
+              </StyledAlert>
+            )}
+            <ItemContent>
+              <AppStoreCredentials
+                api={api}
+                orgSlug={orgSlug}
+                projectSlug={projectSlug}
+                data={appStoreCredentialsData}
+                onChange={setAppStoreCredentialsData}
+                isUpdating={isUpdating}
+              />
+            </ItemContent>
+          </ListItem>
+          <ListItem>
+            <ItemTitle>{t('iTunes credentials')}</ItemTitle>
+            {!!appStoreConnenctContext?.itunesSessionValid && (
+              <StyledAlert type="warning" icon={<IconWarning />}>
+                {t(
+                  'Your iTunes session has expired. To reconnect, sign in with your Apple ID and password'
+                )}
+              </StyledAlert>
+            )}
+            <ItemContent>
+              <ItunesCredentials
+                api={api}
+                orgSlug={orgSlug}
+                projectSlug={projectSlug}
+                data={itunesCredentialsData}
+                onChange={setItunesCredentialsData}
+                isUpdating={isUpdating}
+              />
+            </ItemContent>
+          </ListItem>
+        </StyledList>
+      </Body>
+      <Footer>
+        <ButtonBar gap={1.5}>
+          <Button onClick={closeModal}>{t('Cancel')}</Button>
+          <StyledButton
+            priority="primary"
+            onClick={handleSave}
+            disabled={isFormInvalid() || isLoading}
+          >
+            {t('Save')}
+          </StyledButton>
+        </ButtonBar>
+      </Footer>
+    </Fragment>
+  );
+}
+
+export default withApi(AppStoreConnect);
+
+const StyledList = styled(List)`
+  grid-gap: 0;
+  & > li {
+    padding-left: 0;
+    display: grid;
+    grid-gap: ${space(1)};
+  }
+`;
+
+const ItemTitle = styled('div')`
+  padding-left: ${space(4)};
+  margin-bottom: ${space(1)};
+`;
+
+const ItemContent = styled(Panel)`
+  padding: ${space(3)} ${space(3)} ${space(2)} ${space(1)};
+`;
+
+const StyledButton = styled(Button)`
+  position: relative;
+`;
+
+const StyledAlert = styled(Alert)`
+  margin-bottom: ${space(1)};
+`;

+ 345 - 0
static/app/components/modals/debugFileCustomRepository/appStoreConnect/itunesCredentials/form.tsx

@@ -0,0 +1,345 @@
+import {Fragment, useEffect, useState} from 'react';
+import styled from '@emotion/styled';
+
+import {addErrorMessage} from 'app/actionCreators/indicator';
+import {Client} from 'app/api';
+import Alert from 'app/components/alert';
+import Button from 'app/components/button';
+import ButtonBar from 'app/components/buttonBar';
+import {IconInfo, IconMobile, IconRefresh, IconWarning} from 'app/icons';
+import {t} from 'app/locale';
+import space from 'app/styles/space';
+import {Organization, Project} from 'app/types';
+import Input from 'app/views/settings/components/forms/controls/input';
+import Field from 'app/views/settings/components/forms/field';
+import SelectField from 'app/views/settings/components/forms/selectField';
+
+import Stepper from '../stepper';
+import StepActions from '../stepper/stepActions';
+import {
+  AppleStoreOrg,
+  ItunesCredentialsData,
+  ItunesCredentialsStepOneData,
+  ItunesCredentialsStepThreeData,
+  ItunesCredentialsStepTwoData,
+} from '../types';
+
+const steps = [
+  t('Enter your credentials'),
+  t('Enter your authentication code'),
+  t('Choose an organization'),
+];
+
+type Props = {
+  api: Client;
+  orgSlug: Organization['slug'];
+  projectSlug: Project['slug'];
+  data: ItunesCredentialsData;
+  onChange: (data: ItunesCredentialsData) => void;
+  onCancel?: () => void;
+};
+
+function Form({api, orgSlug, projectSlug, data, onChange, onCancel}: Props) {
+  const [activeStep, setActiveStep] = useState(0);
+  const [isLoading, setIsLoading] = useState(false);
+  const [sessionContext, setSessionContext] = useState('');
+  const [useSms, setUseSms] = useState(false);
+  const [appStoreOrgs, setAppStoreOrgs] = useState<AppleStoreOrg[]>([]);
+
+  const [stepOneData, setSetpOneData] = useState<ItunesCredentialsStepOneData>({
+    username: data.username,
+    password: data.password,
+  });
+
+  const [stepTwoData, setStepTwoData] = useState<ItunesCredentialsStepTwoData>({
+    authenticationCode: data.authenticationCode,
+  });
+
+  const [stepThreeData, setStepThreeData] = useState<ItunesCredentialsStepThreeData>({
+    org: data.org,
+  });
+
+  useEffect(() => {
+    onChange({...stepOneData, ...stepTwoData, ...stepThreeData, sessionContext, useSms});
+  }, [stepThreeData]);
+
+  function isFormInvalid() {
+    switch (activeStep) {
+      case 0:
+        return Object.keys(stepOneData).some(key => !stepOneData[key]?.trim());
+      case 1:
+        return Object.keys(stepTwoData).some(key => !stepTwoData[key]?.trim());
+      case 2:
+        return Object.keys(stepThreeData).some(key => !stepThreeData[key]);
+      default:
+        return false;
+    }
+  }
+
+  function goNext() {
+    setActiveStep(prevActiveStep => prevActiveStep + 1);
+  }
+
+  function handleGoBack() {
+    const newActiveStep = activeStep - 1;
+
+    switch (newActiveStep) {
+      case 1:
+        startItunesAuthentication(false);
+        setStepTwoData({authenticationCode: undefined});
+        break;
+      default:
+        break;
+    }
+
+    setActiveStep(newActiveStep);
+  }
+
+  function handleGoNext() {
+    switch (activeStep) {
+      case 0:
+        startItunesAuthentication();
+        break;
+      case 1:
+        startTwoFactorAuthentication();
+        break;
+      default:
+        break;
+    }
+  }
+
+  async function startItunesAuthentication(shouldGoNext = true) {
+    if (shouldGoNext) {
+      setIsLoading(true);
+    }
+    if (useSms) {
+      setUseSms(false);
+    }
+
+    try {
+      const response = await api.requestPromise(
+        `/projects/${orgSlug}/${projectSlug}/appstoreconnect/start/`,
+        {
+          method: 'POST',
+          data: {
+            itunesUser: stepOneData.username,
+            itunesPassword: stepOneData.password,
+          },
+        }
+      );
+
+      setSessionContext(response.sessionContext);
+
+      if (shouldGoNext) {
+        setIsLoading(false);
+        goNext();
+      }
+    } catch {
+      if (shouldGoNext) {
+        setIsLoading(false);
+      }
+      addErrorMessage(
+        t('The iTunes authentication failed. Please check the entered credentials.')
+      );
+    }
+  }
+
+  async function startTwoFactorAuthentication() {
+    setIsLoading(true);
+    try {
+      const response = await api.requestPromise(
+        `/projects/${orgSlug}/${projectSlug}/appstoreconnect/2fa/`,
+        {
+          method: 'POST',
+          data: {
+            code: stepTwoData.authenticationCode,
+            useSms,
+            sessionContext,
+          },
+        }
+      );
+      setIsLoading(false);
+      const {organizations, sessionContext: newSessionContext} = response;
+      setStepThreeData({org: organizations[0]});
+      setAppStoreOrgs(organizations);
+      setSessionContext(newSessionContext);
+      goNext();
+    } catch {
+      setIsLoading(false);
+      addErrorMessage(
+        t('The two factor authentication failed. Please check the entered code.')
+      );
+    }
+  }
+
+  async function startSmsAuthentication() {
+    if (!useSms) {
+      setUseSms(true);
+    }
+
+    try {
+      const response = await api.requestPromise(
+        `/projects/${orgSlug}/${projectSlug}/appstoreconnect/requestSms/`,
+        {
+          method: 'POST',
+          data: {sessionContext},
+        }
+      );
+
+      setSessionContext(response.sessionContext);
+    } catch {
+      addErrorMessage(t('An error occured while sending the SMS. Please try again'));
+    }
+  }
+
+  function renderStepContent(stepIndex: number) {
+    switch (stepIndex) {
+      case 0:
+        return (
+          <Fragment>
+            <Field
+              label={t('Username')}
+              inline={false}
+              flexibleControlStateSize
+              stacked
+              required
+            >
+              <Input
+                type="text"
+                name="username"
+                placeholder={t('Username')}
+                onChange={e => setSetpOneData({...stepOneData, username: e.target.value})}
+              />
+            </Field>
+            <Field
+              label={t('Password')}
+              inline={false}
+              flexibleControlStateSize
+              stacked
+              required
+            >
+              <Input
+                type="password"
+                name="password"
+                placeholder={t('Password')}
+                onChange={e => setSetpOneData({...stepOneData, password: e.target.value})}
+              />
+            </Field>
+          </Fragment>
+        );
+      case 1:
+        return (
+          <Fragment>
+            <StyledAlert type="info" icon={<IconInfo />}>
+              <AlertContent>
+                {t('Did not get a verification code?')}
+                <ButtonBar gap={1}>
+                  <Button
+                    size="small"
+                    title={t('Get a new verification code')}
+                    onClick={() => startItunesAuthentication(false)}
+                    icon={<IconRefresh />}
+                  >
+                    {t('Resend code')}
+                  </Button>
+                  <Button
+                    size="small"
+                    title={t('Get a text message with a code')}
+                    onClick={() => startSmsAuthentication()}
+                    icon={<IconMobile />}
+                  >
+                    {t('Text me')}
+                  </Button>
+                </ButtonBar>
+              </AlertContent>
+            </StyledAlert>
+            <Field
+              label={t('Two Factor authentication code')}
+              inline={false}
+              flexibleControlStateSize
+              stacked
+              required
+            >
+              <Input
+                type="text"
+                name="two-factor-authentication-code"
+                placeholder={t('Enter your code')}
+                value={stepTwoData.authenticationCode}
+                onChange={e =>
+                  setStepTwoData({
+                    ...setStepTwoData,
+                    authenticationCode: e.target.value,
+                  })
+                }
+              />
+            </Field>
+          </Fragment>
+        );
+      case 2:
+        return (
+          <StyledSelectField
+            name="organization"
+            label={t('iTunes Organization')}
+            choices={appStoreOrgs.map(appStoreOrg => [
+              appStoreOrg.organizationId,
+              appStoreOrg.name,
+            ])}
+            placeholder={t('Select organization')}
+            onChange={organizationId => {
+              const selectedAppStoreOrg = appStoreOrgs.find(
+                appStoreOrg => appStoreOrg.organizationId === organizationId
+              );
+              setStepThreeData({org: selectedAppStoreOrg});
+            }}
+            value={stepThreeData.org?.organizationId ?? ''}
+            inline={false}
+            flexibleControlStateSize
+            stacked
+            required
+          />
+        );
+      default:
+        return (
+          <Alert type="error" icon={<IconWarning />}>
+            {t('This step could not be found.')}
+          </Alert>
+        );
+    }
+  }
+
+  return (
+    <Stepper
+      activeStep={activeStep}
+      steps={steps}
+      renderStepContent={index => renderStepContent(index)}
+      renderStepActions={index => (
+        <StepActions
+          onGoBack={index !== 0 ? handleGoBack : undefined}
+          onGoNext={index !== steps.length - 1 ? handleGoNext : undefined}
+          onCancel={onCancel}
+          goNextDisabled={isFormInvalid() || isLoading}
+          isLoading={isLoading}
+        />
+      )}
+    />
+  );
+}
+
+export default Form;
+
+const StyledAlert = styled(Alert)`
+  display: grid;
+  grid-template-columns: max-content 1fr;
+  align-items: center;
+`;
+
+const AlertContent = styled('div')`
+  display: grid;
+  grid-template-columns: 1fr max-content;
+  align-items: center;
+  grid-gap: ${space(2)};
+`;
+
+const StyledSelectField = styled(SelectField)`
+  padding-right: 0;
+`;

+ 36 - 0
static/app/components/modals/debugFileCustomRepository/appStoreConnect/itunesCredentials/index.tsx

@@ -0,0 +1,36 @@
+import {useState} from 'react';
+
+import {Client} from 'app/api';
+import {Organization, Project} from 'app/types';
+
+import Card from '../card';
+import {ItunesCredentialsData} from '../types';
+
+import Form from './form';
+
+type Props = {
+  api: Client;
+  orgSlug: Organization['slug'];
+  projectSlug: Project['slug'];
+  isUpdating: boolean;
+  data: ItunesCredentialsData;
+  onChange: (data: ItunesCredentialsData) => void;
+};
+
+function ItunesCredentials({data, isUpdating, ...props}: Props) {
+  const [isEditing, setIsEditing] = useState(!isUpdating);
+
+  if (isEditing) {
+    return (
+      <Form
+        {...props}
+        data={data}
+        onCancel={isUpdating ? () => setIsEditing(false) : undefined}
+      />
+    );
+  }
+
+  return <Card data={data} onDelete={() => setIsEditing(true)} />;
+}
+
+export default ItunesCredentials;

+ 57 - 0
static/app/components/modals/debugFileCustomRepository/appStoreConnect/stepper/index.tsx

@@ -0,0 +1,57 @@
+import {Fragment, useEffect, useRef, useState} from 'react';
+
+import Step from './step';
+
+type Props = {
+  activeStep: number;
+  steps: string[];
+  renderStepContent: (stepIndex: number) => React.ReactNode;
+  renderStepActions: (stepIndex: number) => React.ReactNode;
+};
+
+function Stepper({activeStep, steps, renderStepContent, renderStepActions}: Props) {
+  const [stepHeights, setStepHeights] = useState<number[]>([]);
+
+  useEffect(() => {
+    calcStepContentHeights();
+  }, []);
+
+  const wrapperRef = useRef<HTMLDivElement>(null);
+
+  function calcStepContentHeights() {
+    const stepperElement = wrapperRef.current;
+    if (stepperElement) {
+      const newStepHeights = steps.map(
+        (_step, index) => (stepperElement.children[index] as HTMLDivElement).offsetHeight
+      );
+
+      setStepHeights(newStepHeights);
+    }
+  }
+
+  return (
+    <div ref={wrapperRef}>
+      {steps.map((step, index) => {
+        const isActive = !stepHeights.length || activeStep === index;
+        return (
+          <Step
+            key={step}
+            label={step}
+            activeStep={activeStep}
+            isActive={isActive}
+            height={!!stepHeights.length ? stepHeights[index] : undefined}
+          >
+            {isActive && (
+              <Fragment>
+                {renderStepContent(index)}
+                {renderStepActions(index)}
+              </Fragment>
+            )}
+          </Step>
+        );
+      })}
+    </div>
+  );
+}
+
+export default Stepper;

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