Browse Source

feat(relocation): Public key download screen (#61578)

Add the public key download screen.

![Screenshot 2023-12-08 at 15 42
57](https://github.com/getsentry/sentry/assets/3709945/2056c56f-8f25-420f-88a8-11ec4d0a1fc8)
![Screenshot 2023-12-08 at 15 42
20](https://github.com/getsentry/sentry/assets/3709945/6f4edc60-c85f-4f92-8836-113ca321081b)
![Screenshot 2023-12-08 at 15 41
37](https://github.com/getsentry/sentry/assets/3709945/23fad0f8-18a2-4a58-bf0e-66bdb5a79327)


Issue: getsentry/team-ospo#214
Alex Zaslavsky 1 year ago
parent
commit
2d4cb793b2

+ 8 - 1
static/app/components/codeSnippet.tsx

@@ -11,7 +11,6 @@ import {loadPrismLanguage} from 'sentry/utils/prism';
 
 
 interface CodeSnippetProps {
 interface CodeSnippetProps {
   children: string;
   children: string;
-  language: string;
   className?: string;
   className?: string;
   dark?: boolean;
   dark?: boolean;
   ['data-render-inline']?: boolean;
   ['data-render-inline']?: boolean;
@@ -23,10 +22,12 @@ interface CodeSnippetProps {
   disableUserSelection?: boolean;
   disableUserSelection?: boolean;
   filename?: string;
   filename?: string;
   hideCopyButton?: boolean;
   hideCopyButton?: boolean;
+  icon?: React.ReactNode;
   /**
   /**
    * Controls whether the snippet wrapper has rounded corners.
    * Controls whether the snippet wrapper has rounded corners.
    */
    */
   isRounded?: boolean;
   isRounded?: boolean;
+  language?: string;
   /**
   /**
    * Fires after the code snippet is highlighted and all DOM nodes are available
    * Fires after the code snippet is highlighted and all DOM nodes are available
    * @param element The root element of the code snippet
    * @param element The root element of the code snippet
@@ -54,6 +55,7 @@ export function CodeSnippet({
   filename,
   filename,
   hideCopyButton,
   hideCopyButton,
   language,
   language,
+  icon,
   isRounded = true,
   isRounded = true,
   onAfterHighlight,
   onAfterHighlight,
   onCopy,
   onCopy,
@@ -70,6 +72,10 @@ export function CodeSnippet({
       return;
       return;
     }
     }
 
 
+    if (!language) {
+      return;
+    }
+
     if (language in Prism.languages) {
     if (language in Prism.languages) {
       Prism.highlightElement(element, false, () => onAfterHighlight?.(element));
       Prism.highlightElement(element, false, () => onAfterHighlight?.(element));
       return;
       return;
@@ -129,6 +135,7 @@ export function CodeSnippet({
             <FlexSpacer />
             <FlexSpacer />
           </Fragment>
           </Fragment>
         )}
         )}
+        {icon}
         {filename && <FileName>{filename}</FileName>}
         {filename && <FileName>{filename}</FileName>}
         {!hasTabs && <FlexSpacer />}
         {!hasTabs && <FlexSpacer />}
         {!hideCopyButton && (
         {!hideCopyButton && (

+ 11 - 0
static/app/views/relocation/components/relocationCodeBlock.tsx

@@ -0,0 +1,11 @@
+import styled from '@emotion/styled';
+
+import {CodeSnippet} from 'sentry/components/codeSnippet';
+import {space} from 'sentry/styles/space';
+
+const RelocationCodeBlock = styled(CodeSnippet)`
+  margin: ${space(2)} 0 ${space(4)};
+  padding: 4px;
+`;
+
+export default RelocationCodeBlock;

+ 37 - 0
static/app/views/relocation/components/wrapper.tsx

@@ -0,0 +1,37 @@
+import styled from '@emotion/styled';
+
+import {space} from 'sentry/styles/space';
+
+const Wrapper = styled('div')`
+  max-width: 769px;
+  max-height: 525px;
+  margin-left: auto;
+  margin-right: auto;
+  padding: ${space(4)};
+  background-color: ${p => p.theme.surface400};
+  z-index: 100;
+  box-shadow: 0 5px 10px rgba(0, 0, 0, 0.05);
+  border-radius: 10px;
+  width: 100%;
+  color: ${p => p.theme.gray300};
+  mark {
+    border-radius: 8px;
+    padding: ${space(0.25)} ${space(0.5)} ${space(0.25)} ${space(0.5)};
+    background: ${p => p.theme.gray100};
+    margin-right: ${space(1)};
+  }
+  h2 {
+    color: ${p => p.theme.gray500};
+  }
+  p {
+    margin: ${space(1)} ${space(0.5)};
+  }
+  svg {
+    margin: ${space(0.5)};
+  }
+  .encrypt-help {
+    color: ${p => p.theme.gray500};
+  }
+`;
+
+export default Wrapper;

+ 7 - 38
static/app/views/relocation/encryptBackup.tsx

@@ -2,11 +2,13 @@ import styled from '@emotion/styled';
 import {motion} from 'framer-motion';
 import {motion} from 'framer-motion';
 
 
 import {Button} from 'sentry/components/button';
 import {Button} from 'sentry/components/button';
-import {CodeSnippet} from 'sentry/components/codeSnippet';
+import {IconTerminal} from 'sentry/icons';
 import {t} from 'sentry/locale';
 import {t} from 'sentry/locale';
 import {space} from 'sentry/styles/space';
 import {space} from 'sentry/styles/space';
 import testableTransition from 'sentry/utils/testableTransition';
 import testableTransition from 'sentry/utils/testableTransition';
+import RelocationCodeBlock from 'sentry/views/relocation/components/relocationCodeBlock';
 import StepHeading from 'sentry/views/relocation/components/stepHeading';
 import StepHeading from 'sentry/views/relocation/components/stepHeading';
+import Wrapper from 'sentry/views/relocation/components/wrapper';
 
 
 import {StepProps} from './types';
 import {StepProps} from './types';
 
 
@@ -31,14 +33,15 @@ export function EncryptBackup(props: StepProps) {
             'You’ll need to have the public key saved in the previous step accessible when you run the following command in your terminal. Make sure your current working directory is the root of your `self-hosted` install when you execute it.'
             'You’ll need to have the public key saved in the previous step accessible when you run the following command in your terminal. Make sure your current working directory is the root of your `self-hosted` install when you execute it.'
           )}
           )}
         </p>
         </p>
-        <EncryptCodeSnippet
+        <RelocationCodeBlock
           dark
           dark
           language="bash"
           language="bash"
-          filename=">_ TERMINAL"
+          filename="TERMINAL"
+          icon={<IconTerminal />}
           hideCopyButton={false}
           hideCopyButton={false}
         >
         >
           {code}
           {code}
-        </EncryptCodeSnippet>
+        </RelocationCodeBlock>
         <p className="encrypt-help">
         <p className="encrypt-help">
           <b>{t('Understanding the command:')}</b>
           <b>{t('Understanding the command:')}</b>
         </p>
         </p>
@@ -64,40 +67,6 @@ export function EncryptBackup(props: StepProps) {
 
 
 export default EncryptBackup;
 export default EncryptBackup;
 
 
-const EncryptCodeSnippet = styled(CodeSnippet)`
-  margin: ${space(2)} 0 ${space(4)};
-  padding: 4px;
-`;
-
-const Wrapper = styled('div')`
-  max-width: 769px;
-  max-height: 525px;
-  margin-left: auto;
-  margin-right: auto;
-  padding: ${space(4)};
-  background-color: ${p => p.theme.surface400};
-  z-index: 100;
-  box-shadow: 0 5px 10px rgba(0, 0, 0, 0.05);
-  border-radius: 10px;
-  width: 100%;
-  color: ${p => p.theme.gray300};
-  mark {
-    border-radius: 8px;
-    padding: ${space(0.25)} ${space(0.5)} ${space(0.25)} ${space(0.5)};
-    background: ${p => p.theme.gray100};
-    margin-right: ${space(1)};
-  }
-  h2 {
-    color: ${p => p.theme.gray500};
-  }
-  p {
-    margin-bottom: ${space(1)};
-  }
-  .encrypt-help {
-    color: ${p => p.theme.gray500};
-  }
-`;
-
 const ContinueButton = styled(Button)`
 const ContinueButton = styled(Button)`
   margin-top: ${space(1.5)};
   margin-top: ${space(1.5)};
 `;
 `;

+ 1 - 1
static/app/views/relocation/getStarted.tsx

@@ -5,12 +5,12 @@ import {motion} from 'framer-motion';
 import {Button} from 'sentry/components/button';
 import {Button} from 'sentry/components/button';
 import SelectControl from 'sentry/components/forms/controls/selectControl';
 import SelectControl from 'sentry/components/forms/controls/selectControl';
 import Input from 'sentry/components/input';
 import Input from 'sentry/components/input';
-import {RelocationOnboardingContext} from 'sentry/components/onboarding/relocationOnboardingContext';
 import {t} from 'sentry/locale';
 import {t} from 'sentry/locale';
 import ConfigStore from 'sentry/stores/configStore';
 import ConfigStore from 'sentry/stores/configStore';
 import {space} from 'sentry/styles/space';
 import {space} from 'sentry/styles/space';
 import testableTransition from 'sentry/utils/testableTransition';
 import testableTransition from 'sentry/utils/testableTransition';
 import StepHeading from 'sentry/views/relocation/components/stepHeading';
 import StepHeading from 'sentry/views/relocation/components/stepHeading';
+import {RelocationOnboardingContext} from 'sentry/views/relocation/relocationOnboardingContext';
 
 
 import {StepProps} from './types';
 import {StepProps} from './types';
 
 

+ 28 - 0
static/app/views/relocation/index.spec.tsx

@@ -5,7 +5,35 @@ import ConfigStore from 'sentry/stores/configStore';
 
 
 import RelocationOnboardingContainer from './index';
 import RelocationOnboardingContainer from './index';
 
 
+const fakePublicKey = `-----BEGIN PUBLIC KEY-----
+MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAw5Or1zsGE1XJTL4q+1c4
+Ztu8+7SC/exrnEYlWH+LVLI8TVyuGwDTAXrgKHGwaMM5ZnjijP5i8+ph8lfLrybT
+l+2D81qPIqagEtNMDaHqUDm5Tq7I2qvxkJ5YuDLawRUPccKMwWlIDR2Gvfe3efce
+870EicPsExz4uPOkNXGHJZ/FwCQrLo87MXFeqrqj+0Cf+qwCQSCW9qFWe5cj+zqt
+eeJa0qflcHHQzxK4/EKKpl/hkt4zi0aE/PuJgvJz2KB+X3+LzekTy90LzW3VhR4y
+IAxCAaGQJVsg9dhKOORjAf4XK9aXHvy/jUSyT43opj6AgNqXlKEQjb1NBA8qbJJS
+8wIDAQAB
+-----END PUBLIC KEY-----`;
+
 describe('Relocation Onboarding Container', function () {
 describe('Relocation Onboarding Container', function () {
+  beforeEach(function () {
+    MockApiClient.asyncDelay = undefined;
+    MockApiClient.clearMockResponses();
+    MockApiClient.addMockResponse({
+      url: '/publickeys/relocations/',
+      body: {
+        public_key: fakePublicKey,
+      },
+    });
+
+    // The tests fail because we have a "component update was not wrapped in act" error. It should
+    // be safe to ignore this error, but we should remove the mock once we move to react testing
+    // library.
+    //
+    // eslint-disable-next-line no-console
+    jest.spyOn(console, 'error').mockImplementation(jest.fn());
+  });
+
   it('should render if feature enabled', function () {
   it('should render if feature enabled', function () {
     const {routerProps, routerContext, organization} = initializeOrg({
     const {routerProps, routerContext, organization} = initializeOrg({
       router: {
       router: {

+ 75 - 0
static/app/views/relocation/publicKey.tsx

@@ -0,0 +1,75 @@
+import styled from '@emotion/styled';
+import {motion} from 'framer-motion';
+
+import {Button} from 'sentry/components/button';
+import LoadingIndicator from 'sentry/components/loadingIndicator';
+import {IconFile} from 'sentry/icons/iconFile';
+import {t} from 'sentry/locale';
+import {space} from 'sentry/styles/space';
+import testableTransition from 'sentry/utils/testableTransition';
+import RelocationCodeBlock from 'sentry/views/relocation/components/relocationCodeBlock';
+import StepHeading from 'sentry/views/relocation/components/stepHeading';
+import Wrapper from 'sentry/views/relocation/components/wrapper';
+
+import {StepProps} from './types';
+
+export function PublicKey({publicKey, onComplete}: StepProps) {
+  const handleContinue = (event: any) => {
+    event.preventDefault();
+    onComplete();
+  };
+
+  const loaded = (
+    <motion.div
+      transition={testableTransition()}
+      variants={{
+        initial: {y: 30, opacity: 0},
+        animate: {y: 0, opacity: 1},
+        exit: {opacity: 0},
+      }}
+    >
+      <p>
+        {t(
+          "To do so, you'll need to save the following public key to a file accessible from wherever your self-hosted repository is currently installed. You'll need to have this public key file available for the next step."
+        )}
+      </p>
+      <RelocationCodeBlock
+        dark
+        filename="key.pub"
+        icon={<IconFile />}
+        hideCopyButton={false}
+      >
+        {publicKey}
+      </RelocationCodeBlock>
+      <ContinueButton size="md" priority="primary" type="submit" onClick={handleContinue}>
+        {t('Continue')}
+      </ContinueButton>
+    </motion.div>
+  );
+
+  const unloaded = (
+    <motion.div
+      transition={testableTransition()}
+      variants={{
+        initial: {y: 30, opacity: 0},
+        animate: {y: 0, opacity: 1},
+        exit: {opacity: 0},
+      }}
+    >
+      <LoadingIndicator />
+    </motion.div>
+  );
+
+  return (
+    <Wrapper>
+      <StepHeading step={2}>{t("Save Sentry's public key to your machine")}</StepHeading>
+      {publicKey ? loaded : unloaded}
+    </Wrapper>
+  );
+}
+
+export default PublicKey;
+
+const ContinueButton = styled(Button)`
+  margin-top: ${space(1.5)};
+`;

+ 104 - 7
static/app/views/relocation/relocation.spec.tsx

@@ -1,10 +1,46 @@
 import {initializeOrg} from 'sentry-test/initializeOrg';
 import {initializeOrg} from 'sentry-test/initializeOrg';
-import {render, screen, userEvent} from 'sentry-test/reactTestingLibrary';
+import {render, screen, userEvent, waitFor} from 'sentry-test/reactTestingLibrary';
 
 
 import ConfigStore from 'sentry/stores/configStore';
 import ConfigStore from 'sentry/stores/configStore';
 import Relocation from 'sentry/views/relocation/relocation';
 import Relocation from 'sentry/views/relocation/relocation';
 
 
+const fakePublicKey = `-----BEGIN PUBLIC KEY-----
+MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAw5Or1zsGE1XJTL4q+1c4
+Ztu8+7SC/exrnEYlWH+LVLI8TVyuGwDTAXrgKHGwaMM5ZnjijP5i8+ph8lfLrybT
+l+2D81qPIqagEtNMDaHqUDm5Tq7I2qvxkJ5YuDLawRUPccKMwWlIDR2Gvfe3efce
+870EicPsExz4uPOkNXGHJZ/FwCQrLo87MXFeqrqj+0Cf+qwCQSCW9qFWe5cj+zqt
+eeJa0qflcHHQzxK4/EKKpl/hkt4zi0aE/PuJgvJz2KB+X3+LzekTy90LzW3VhR4y
+IAxCAaGQJVsg9dhKOORjAf4XK9aXHvy/jUSyT43opj6AgNqXlKEQjb1NBA8qbJJS
+8wIDAQAB
+-----END PUBLIC KEY-----`;
+
 describe('Relocation', function () {
 describe('Relocation', function () {
+  let fetchPublicKey: jest.Mock;
+
+  beforeEach(function () {
+    MockApiClient.asyncDelay = undefined;
+    MockApiClient.clearMockResponses();
+    fetchPublicKey = MockApiClient.addMockResponse({
+      url: '/publickeys/relocations/',
+      body: {
+        public_key: fakePublicKey,
+      },
+    });
+
+    // The tests fail because we have a "component update was not wrapped in act" error. It should
+    // be safe to ignore this error, but we should remove the mock once we move to react testing
+    // library.
+    //
+    // eslint-disable-next-line no-console
+    jest.spyOn(console, 'error').mockImplementation(jest.fn());
+  });
+
+  afterEach(function () {
+    // console.error = consoleError;
+    MockApiClient.clearMockResponses();
+    MockApiClient.asyncDelay = undefined;
+  });
+
   function renderPage(step) {
   function renderPage(step) {
     const routeParams = {
     const routeParams = {
       step,
       step,
@@ -21,9 +57,12 @@ describe('Relocation', function () {
       organization,
       organization,
     });
     });
   }
   }
+
   describe('Get Started', function () {
   describe('Get Started', function () {
     it('renders', async function () {
     it('renders', async function () {
       renderPage('get-started');
       renderPage('get-started');
+      await waitFor(() => expect(fetchPublicKey).toHaveBeenCalled());
+
       expect(
       expect(
         await screen.findByText('Basic information needed to get started')
         await screen.findByText('Basic information needed to get started')
       ).toBeInTheDocument();
       ).toBeInTheDocument();
@@ -33,26 +72,84 @@ describe('Relocation', function () {
       expect(await screen.findByText('Choose a datacenter region')).toBeInTheDocument();
       expect(await screen.findByText('Choose a datacenter region')).toBeInTheDocument();
     });
     });
 
 
-    it('should prevent user from going to the next step if no org slugs or region are entered', function () {
+    it('should prevent user from going to the next step if no org slugs or region are entered', async function () {
       renderPage('get-started');
       renderPage('get-started');
-      expect(screen.getByRole('button', {name: 'Continue'})).toBeDisabled();
+      await waitFor(() => expect(fetchPublicKey).toHaveBeenCalled());
+
+      expect(await screen.getByRole('button', {name: 'Continue'})).toBeDisabled();
     });
     });
 
 
     it('should be allowed to go to next step if org slug is entered and region is selected', async function () {
     it('should be allowed to go to next step if org slug is entered and region is selected', async function () {
       renderPage('get-started');
       renderPage('get-started');
+      await waitFor(() => expect(fetchPublicKey).toHaveBeenCalled());
+
       ConfigStore.set('regions', [{name: 'USA', url: 'https://example.com'}]);
       ConfigStore.set('regions', [{name: 'USA', url: 'https://example.com'}]);
-      const orgSlugsInput = screen.getByLabelText('org-slugs');
-      const continueButton = screen.getByRole('button', {name: 'Continue'});
+      const orgSlugsInput = await screen.getByLabelText('org-slugs');
+      const continueButton = await screen.getByRole('button', {name: 'Continue'});
       await userEvent.type(orgSlugsInput, 'test-org');
       await userEvent.type(orgSlugsInput, 'test-org');
-      await userEvent.type(screen.getByLabelText('region'), 'U');
-      await userEvent.click(screen.getByRole('menuitemradio'));
+      await userEvent.type(await screen.getByLabelText('region'), 'U');
+      await userEvent.click(await screen.getByRole('menuitemradio'));
       expect(continueButton).toBeEnabled();
       expect(continueButton).toBeEnabled();
     });
     });
   });
   });
 
 
+  describe('Public Key', function () {
+    it('should show instructions if key retrieval was successful', async function () {
+      renderPage('public-key');
+      await waitFor(() => expect(fetchPublicKey).toHaveBeenCalled());
+
+      expect(
+        await screen.findByText("Save Sentry's public key to your machine")
+      ).toBeInTheDocument();
+      expect(await screen.getByText('key.pub')).toBeInTheDocument();
+      expect(await screen.getByRole('button', {name: 'Continue'})).toBeInTheDocument();
+    });
+
+    it('should show loading indicator if key retrieval still in progress', function () {
+      MockApiClient.asyncDelay = 1;
+
+      renderPage('public-key');
+
+      expect(screen.queryByRole('button', {name: 'Continue'})).not.toBeInTheDocument();
+      expect(screen.queryByText('key.pub')).not.toBeInTheDocument();
+    });
+
+    it('should show loading indicator and error message if key retrieval failed', async function () {
+      MockApiClient.clearMockResponses();
+      fetchPublicKey = MockApiClient.addMockResponse({
+        url: '/publickeys/relocations/',
+        statusCode: 400,
+      });
+
+      renderPage('public-key');
+      await waitFor(() => expect(fetchPublicKey).toHaveBeenCalled());
+
+      expect(
+        await screen.queryByRole('button', {name: 'Continue'})
+      ).not.toBeInTheDocument();
+      expect(await screen.queryByText('key.pub')).not.toBeInTheDocument();
+      expect(await screen.getByRole('button', {name: 'Retry'})).toBeInTheDocument();
+
+      MockApiClient.addMockResponse({
+        url: '/publickeys/relocations/',
+        body: {
+          public_key: fakePublicKey,
+        },
+      });
+
+      await userEvent.click(screen.getByRole('button', {name: 'Retry'}));
+      await waitFor(() => expect(fetchPublicKey).toHaveBeenCalled());
+
+      expect(await screen.queryByText('key.pub')).toBeInTheDocument();
+      expect(await screen.queryByRole('button', {name: 'Continue'})).toBeInTheDocument();
+    });
+  });
+
   describe('Select Platform', function () {
   describe('Select Platform', function () {
     it('renders', async function () {
     it('renders', async function () {
       renderPage('encrypt-backup');
       renderPage('encrypt-backup');
+      await waitFor(() => expect(fetchPublicKey).toHaveBeenCalled());
+
       expect(
       expect(
         await screen.findByText(
         await screen.findByText(
           'Create an encrypted backup of current self-hosted instance'
           'Create an encrypted backup of current self-hosted instance'

+ 42 - 3
static/app/views/relocation/relocation.tsx

@@ -1,24 +1,27 @@
-import {useCallback, useEffect, useRef} from 'react';
+import {useCallback, useEffect, useRef, useState} from 'react';
 import {RouteComponentProps} from 'react-router';
 import {RouteComponentProps} from 'react-router';
 import styled from '@emotion/styled';
 import styled from '@emotion/styled';
 import {AnimatePresence, motion, MotionProps, useAnimation} from 'framer-motion';
 import {AnimatePresence, motion, MotionProps, useAnimation} from 'framer-motion';
 
 
 import {Button, ButtonProps} from 'sentry/components/button';
 import {Button, ButtonProps} from 'sentry/components/button';
+import LoadingError from 'sentry/components/loadingError';
 import LogoSentry from 'sentry/components/logoSentry';
 import LogoSentry from 'sentry/components/logoSentry';
-import {RelocationOnboardingContextProvider} from 'sentry/components/onboarding/relocationOnboardingContext';
 import SentryDocumentTitle from 'sentry/components/sentryDocumentTitle';
 import SentryDocumentTitle from 'sentry/components/sentryDocumentTitle';
 import {IconArrow} from 'sentry/icons';
 import {IconArrow} from 'sentry/icons';
 import {t} from 'sentry/locale';
 import {t} from 'sentry/locale';
 import {space} from 'sentry/styles/space';
 import {space} from 'sentry/styles/space';
 import Redirect from 'sentry/utils/redirect';
 import Redirect from 'sentry/utils/redirect';
 import testableTransition from 'sentry/utils/testableTransition';
 import testableTransition from 'sentry/utils/testableTransition';
+import useApi from 'sentry/utils/useApi';
 import useOrganization from 'sentry/utils/useOrganization';
 import useOrganization from 'sentry/utils/useOrganization';
 import {normalizeUrl} from 'sentry/utils/withDomainRequired';
 import {normalizeUrl} from 'sentry/utils/withDomainRequired';
 import PageCorners from 'sentry/views/onboarding/components/pageCorners';
 import PageCorners from 'sentry/views/onboarding/components/pageCorners';
 import Stepper from 'sentry/views/onboarding/components/stepper';
 import Stepper from 'sentry/views/onboarding/components/stepper';
+import {RelocationOnboardingContextProvider} from 'sentry/views/relocation/relocationOnboardingContext';
 
 
 import EncryptBackup from './encryptBackup';
 import EncryptBackup from './encryptBackup';
 import GetStarted from './getStarted';
 import GetStarted from './getStarted';
+import PublicKey from './publicKey';
 import {StepDescriptor} from './types';
 import {StepDescriptor} from './types';
 
 
 type RouteParams = {
 type RouteParams = {
@@ -35,6 +38,12 @@ function getOrganizationOnboardingSteps(): StepDescriptor[] {
       Component: GetStarted,
       Component: GetStarted,
       cornerVariant: 'top-left',
       cornerVariant: 'top-left',
     },
     },
+    {
+      id: 'public-key',
+      title: t("Save Sentry's public key to your machine"),
+      Component: PublicKey,
+      cornerVariant: 'top-left',
+    },
     {
     {
       id: 'encrypt-backup',
       id: 'encrypt-backup',
       title: t('Encrypt backup'),
       title: t('Encrypt backup'),
@@ -46,6 +55,34 @@ function getOrganizationOnboardingSteps(): StepDescriptor[] {
 
 
 function RelocationOnboarding(props: Props) {
 function RelocationOnboarding(props: Props) {
   const organization = useOrganization();
   const organization = useOrganization();
+  const [hasPublicKeyError, setHasError] = useState(false);
+
+  // TODO(getsentry/team-ospo#214): We should use sessionStorage to track this, since it should not
+  // change during a single run through this workflow.
+  const [publicKey, setPublicKey] = useState('');
+
+  const api = useApi();
+  const fetchData = useCallback(() => {
+    const endpoint = `/publickeys/relocations/`;
+    return api
+      .requestPromise(endpoint)
+      .then(response => {
+        setPublicKey(response.public_key);
+        setHasError(false);
+      })
+      .catch(_error => {
+        setPublicKey('');
+        setHasError(true);
+      });
+  }, [api]);
+
+  useEffect(() => {
+    fetchData();
+  }, [fetchData]);
+
+  const loadingError = (
+    <LoadingError message={t('Failed to load your public key.')} onRetry={fetchData} />
+  );
 
 
   const {
   const {
     params: {step: stepId},
     params: {step: stepId},
@@ -65,7 +102,7 @@ function RelocationOnboarding(props: Props) {
 
 
   const cornerVariantControl = useAnimation();
   const cornerVariantControl = useAnimation();
   const updateCornerVariant = () => {
   const updateCornerVariant = () => {
-    // TODO: find better way to delay the corner animation
+    // TODO(getsentry/team-ospo#214): Find a better way to delay the corner animation.
     window.clearTimeout(cornerVariantTimeoutRed.current);
     window.clearTimeout(cornerVariantTimeoutRed.current);
 
 
     cornerVariantTimeoutRed.current = window.setTimeout(
     cornerVariantTimeoutRed.current = window.setTimeout(
@@ -151,6 +188,7 @@ function RelocationOnboarding(props: Props) {
                       goNextStep(stepObj);
                       goNextStep(stepObj);
                     }
                     }
                   }}
                   }}
+                  publicKey={publicKey}
                   route={props.route}
                   route={props.route}
                   router={props.router}
                   router={props.router}
                   location={props.location}
                   location={props.location}
@@ -159,6 +197,7 @@ function RelocationOnboarding(props: Props) {
             </OnboardingStep>
             </OnboardingStep>
           </AnimatePresence>
           </AnimatePresence>
           <AdaptivePageCorners animateVariant={cornerVariantControl} />
           <AdaptivePageCorners animateVariant={cornerVariantControl} />
+          {stepObj.id === 'public-key' && hasPublicKeyError ? loadingError : null}
         </Container>
         </Container>
       </RelocationOnboardingContextProvider>
       </RelocationOnboardingContextProvider>
     </OnboardingWrapper>
     </OnboardingWrapper>

+ 0 - 0
static/app/components/onboarding/relocationOnboardingContext.tsx → static/app/views/relocation/relocationOnboardingContext.tsx


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