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 {
   children: string;
-  language: string;
   className?: string;
   dark?: boolean;
   ['data-render-inline']?: boolean;
@@ -23,10 +22,12 @@ interface CodeSnippetProps {
   disableUserSelection?: boolean;
   filename?: string;
   hideCopyButton?: boolean;
+  icon?: React.ReactNode;
   /**
    * Controls whether the snippet wrapper has rounded corners.
    */
   isRounded?: boolean;
+  language?: string;
   /**
    * Fires after the code snippet is highlighted and all DOM nodes are available
    * @param element The root element of the code snippet
@@ -54,6 +55,7 @@ export function CodeSnippet({
   filename,
   hideCopyButton,
   language,
+  icon,
   isRounded = true,
   onAfterHighlight,
   onCopy,
@@ -70,6 +72,10 @@ export function CodeSnippet({
       return;
     }
 
+    if (!language) {
+      return;
+    }
+
     if (language in Prism.languages) {
       Prism.highlightElement(element, false, () => onAfterHighlight?.(element));
       return;
@@ -129,6 +135,7 @@ export function CodeSnippet({
             <FlexSpacer />
           </Fragment>
         )}
+        {icon}
         {filename && <FileName>{filename}</FileName>}
         {!hasTabs && <FlexSpacer />}
         {!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 {Button} from 'sentry/components/button';
-import {CodeSnippet} from 'sentry/components/codeSnippet';
+import {IconTerminal} from 'sentry/icons';
 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';
 
@@ -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.'
           )}
         </p>
-        <EncryptCodeSnippet
+        <RelocationCodeBlock
           dark
           language="bash"
-          filename=">_ TERMINAL"
+          filename="TERMINAL"
+          icon={<IconTerminal />}
           hideCopyButton={false}
         >
           {code}
-        </EncryptCodeSnippet>
+        </RelocationCodeBlock>
         <p className="encrypt-help">
           <b>{t('Understanding the command:')}</b>
         </p>
@@ -64,40 +67,6 @@ export function EncryptBackup(props: StepProps) {
 
 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)`
   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 SelectControl from 'sentry/components/forms/controls/selectControl';
 import Input from 'sentry/components/input';
-import {RelocationOnboardingContext} from 'sentry/components/onboarding/relocationOnboardingContext';
 import {t} from 'sentry/locale';
 import ConfigStore from 'sentry/stores/configStore';
 import {space} from 'sentry/styles/space';
 import testableTransition from 'sentry/utils/testableTransition';
 import StepHeading from 'sentry/views/relocation/components/stepHeading';
+import {RelocationOnboardingContext} from 'sentry/views/relocation/relocationOnboardingContext';
 
 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';
 
+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 () {
+  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 () {
     const {routerProps, routerContext, organization} = initializeOrg({
       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 {render, screen, userEvent} from 'sentry-test/reactTestingLibrary';
+import {render, screen, userEvent, waitFor} from 'sentry-test/reactTestingLibrary';
 
 import ConfigStore from 'sentry/stores/configStore';
 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 () {
+  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) {
     const routeParams = {
       step,
@@ -21,9 +57,12 @@ describe('Relocation', function () {
       organization,
     });
   }
+
   describe('Get Started', function () {
     it('renders', async function () {
       renderPage('get-started');
+      await waitFor(() => expect(fetchPublicKey).toHaveBeenCalled());
+
       expect(
         await screen.findByText('Basic information needed to get started')
       ).toBeInTheDocument();
@@ -33,26 +72,84 @@ describe('Relocation', function () {
       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');
-      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 () {
       renderPage('get-started');
+      await waitFor(() => expect(fetchPublicKey).toHaveBeenCalled());
+
       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(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();
     });
   });
 
+  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 () {
     it('renders', async function () {
       renderPage('encrypt-backup');
+      await waitFor(() => expect(fetchPublicKey).toHaveBeenCalled());
+
       expect(
         await screen.findByText(
           '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 styled from '@emotion/styled';
 import {AnimatePresence, motion, MotionProps, useAnimation} from 'framer-motion';
 
 import {Button, ButtonProps} from 'sentry/components/button';
+import LoadingError from 'sentry/components/loadingError';
 import LogoSentry from 'sentry/components/logoSentry';
-import {RelocationOnboardingContextProvider} from 'sentry/components/onboarding/relocationOnboardingContext';
 import SentryDocumentTitle from 'sentry/components/sentryDocumentTitle';
 import {IconArrow} from 'sentry/icons';
 import {t} from 'sentry/locale';
 import {space} from 'sentry/styles/space';
 import Redirect from 'sentry/utils/redirect';
 import testableTransition from 'sentry/utils/testableTransition';
+import useApi from 'sentry/utils/useApi';
 import useOrganization from 'sentry/utils/useOrganization';
 import {normalizeUrl} from 'sentry/utils/withDomainRequired';
 import PageCorners from 'sentry/views/onboarding/components/pageCorners';
 import Stepper from 'sentry/views/onboarding/components/stepper';
+import {RelocationOnboardingContextProvider} from 'sentry/views/relocation/relocationOnboardingContext';
 
 import EncryptBackup from './encryptBackup';
 import GetStarted from './getStarted';
+import PublicKey from './publicKey';
 import {StepDescriptor} from './types';
 
 type RouteParams = {
@@ -35,6 +38,12 @@ function getOrganizationOnboardingSteps(): StepDescriptor[] {
       Component: GetStarted,
       cornerVariant: 'top-left',
     },
+    {
+      id: 'public-key',
+      title: t("Save Sentry's public key to your machine"),
+      Component: PublicKey,
+      cornerVariant: 'top-left',
+    },
     {
       id: 'encrypt-backup',
       title: t('Encrypt backup'),
@@ -46,6 +55,34 @@ function getOrganizationOnboardingSteps(): StepDescriptor[] {
 
 function RelocationOnboarding(props: Props) {
   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 {
     params: {step: stepId},
@@ -65,7 +102,7 @@ function RelocationOnboarding(props: Props) {
 
   const cornerVariantControl = useAnimation();
   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);
 
     cornerVariantTimeoutRed.current = window.setTimeout(
@@ -151,6 +188,7 @@ function RelocationOnboarding(props: Props) {
                       goNextStep(stepObj);
                     }
                   }}
+                  publicKey={publicKey}
                   route={props.route}
                   router={props.router}
                   location={props.location}
@@ -159,6 +197,7 @@ function RelocationOnboarding(props: Props) {
             </OnboardingStep>
           </AnimatePresence>
           <AdaptivePageCorners animateVariant={cornerVariantControl} />
+          {stepObj.id === 'public-key' && hasPublicKeyError ? loadingError : null}
         </Container>
       </RelocationOnboardingContextProvider>
     </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