Browse Source

feat(onboarding): Add in-snippet auth token generator (#56469)

* Substitute the `___ORG_AUTH_TOKEN___` placeholder in code snippets
with a button for generating the token

Closes https://github.com/getsentry/sentry/issues/55761
ArthurKnaus 1 year ago
parent
commit
eb400538e6

+ 121 - 0
static/app/components/onboarding/gettingStartedDoc/authTokenGenerator.tsx

@@ -0,0 +1,121 @@
+import {createContext, Fragment, useContext, useState} from 'react';
+import styled from '@emotion/styled';
+
+import {addErrorMessage} from 'sentry/actionCreators/indicator';
+import {t} from 'sentry/locale';
+import ConfigStore from 'sentry/stores/configStore';
+import {useLegacyStore} from 'sentry/stores/useLegacyStore';
+import {OrgAuthToken} from 'sentry/types';
+import {handleXhrErrorResponse} from 'sentry/utils/handleXhrErrorResponse';
+import {useMutation} from 'sentry/utils/queryClient';
+import RequestError from 'sentry/utils/requestError/requestError';
+import useApi from 'sentry/utils/useApi';
+import useOrganization from 'sentry/utils/useOrganization';
+
+type OrgAuthTokenWithToken = OrgAuthToken & {token: string};
+
+const AuthTokenGeneratorContext = createContext<{
+  generateAuthToken: () => void;
+  isLoading: boolean;
+  authToken?: string;
+}>({
+  isLoading: false,
+  generateAuthToken: () => {},
+});
+
+interface AuthTokenGeneratorProviderProps {
+  children: React.ReactNode;
+  projectSlug: string;
+}
+
+export function AuthTokenGeneratorProvider({
+  children,
+  projectSlug,
+}: AuthTokenGeneratorProviderProps) {
+  const api = useApi();
+  const organization = useOrganization();
+  const [authToken, setAuthToken] = useState<string>();
+  const config = useLegacyStore(ConfigStore);
+
+  const {mutate: generateAuthToken, isLoading} = useMutation<
+    OrgAuthTokenWithToken,
+    RequestError
+  >({
+    mutationFn: () => {
+      const currentDate = new Date().toISOString().slice(0, 10);
+      const name = `Generated by ${config.user.name} for ${projectSlug} on ${currentDate}`;
+      return api.requestPromise(`/organizations/${organization.slug}/org-auth-tokens/`, {
+        method: 'POST',
+        data: {
+          name,
+        },
+      });
+    },
+
+    onSuccess: (token: OrgAuthTokenWithToken) => {
+      setAuthToken(token.token);
+    },
+    onError: error => {
+      const message = t('Failed to create a new auth token.');
+      handleXhrErrorResponse(message, error);
+      addErrorMessage(message);
+    },
+  });
+
+  return (
+    <AuthTokenGeneratorContext.Provider value={{authToken, isLoading, generateAuthToken}}>
+      {children}
+    </AuthTokenGeneratorContext.Provider>
+  );
+}
+
+export function AuthTokenGenerator() {
+  const {authToken, isLoading, generateAuthToken} = useContext(AuthTokenGeneratorContext);
+
+  function handleClick() {
+    generateAuthToken();
+  }
+
+  function handleKeyDown(event: React.KeyboardEvent<HTMLButtonElement>) {
+    if (['Enter', 'Space'].includes(event.key)) {
+      generateAuthToken();
+    }
+  }
+
+  if (authToken) {
+    return <Fragment>{authToken}</Fragment>;
+  }
+
+  if (isLoading) {
+    return <Wrapper isInteractive={false}>{t('Generating token...')}</Wrapper>;
+  }
+
+  return (
+    <Wrapper
+      isInteractive
+      role="button"
+      tabIndex={0}
+      onClick={handleClick}
+      onKeyDown={handleKeyDown}
+    >
+      {t('Click to generate token')}
+    </Wrapper>
+  );
+}
+
+const Wrapper = styled('span')<{isInteractive: boolean}>`
+  background: var(--prism-highlight-accent);
+  border-radius: 4px;
+  border: none;
+  padding: 0px 2px;
+  margin: 0 4px;
+
+  ${p =>
+    p.isInteractive &&
+    `
+  cursor: pointer;
+
+  &:hover {
+    background: var(--prism-highlight-background);
+  }`}
+`;

+ 36 - 31
static/app/components/onboarding/gettingStartedDoc/layout.tsx

@@ -5,6 +5,7 @@ import HookOrDefault from 'sentry/components/hookOrDefault';
 import ExternalLink from 'sentry/components/links/externalLink';
 import List from 'sentry/components/list';
 import ListItem from 'sentry/components/list/listItem';
+import {AuthTokenGeneratorProvider} from 'sentry/components/onboarding/gettingStartedDoc/authTokenGenerator';
 import {Step, StepProps} from 'sentry/components/onboarding/gettingStartedDoc/step';
 import {PlatformOptionsControl} from 'sentry/components/onboarding/platformOptionsControl';
 import {ProductSelection} from 'sentry/components/onboarding/productSelection';
@@ -25,6 +26,7 @@ type NextStep = {
 };
 
 export type LayoutProps = {
+  projectSlug: string;
   steps: StepProps[];
   /**
    * An introduction displayed before the steps
@@ -43,41 +45,44 @@ export function Layout({
   nextSteps = [],
   platformOptions,
   introduction,
+  projectSlug,
 }: LayoutProps) {
   const organization = useOrganization();
 
   return (
-    <Wrapper>
-      {introduction && <Introduction>{introduction}</Introduction>}
-      <ProductSelectionAvailabilityHook
-        organization={organization}
-        platform={platformKey}
-      />
-      {platformOptions ? (
-        <PlatformOptionsControl platformOptions={platformOptions} />
-      ) : null}
-      <Divider withBottomMargin={newOrg} />
-      <Steps>
-        {steps.map(step => (
-          <Step key={step.title ?? step.type} {...step} />
-        ))}
-      </Steps>
-      {nextSteps.length > 0 && (
-        <Fragment>
-          <Divider />
-          <h4>{t('Next Steps')}</h4>
-          <List symbol="bullet">
-            {nextSteps.map(step => (
-              <ListItem key={step.name}>
-                <ExternalLink href={step.link}>{step.name}</ExternalLink>
-                {': '}
-                {step.description}
-              </ListItem>
-            ))}
-          </List>
-        </Fragment>
-      )}
-    </Wrapper>
+    <AuthTokenGeneratorProvider projectSlug={projectSlug}>
+      <Wrapper>
+        {introduction && <Introduction>{introduction}</Introduction>}
+        <ProductSelectionAvailabilityHook
+          organization={organization}
+          platform={platformKey}
+        />
+        {platformOptions ? (
+          <PlatformOptionsControl platformOptions={platformOptions} />
+        ) : null}
+        <Divider withBottomMargin={newOrg} />
+        <Steps>
+          {steps.map(step => (
+            <Step key={step.title ?? step.type} {...step} />
+          ))}
+        </Steps>
+        {nextSteps.length > 0 && (
+          <Fragment>
+            <Divider />
+            <h4>{t('Next Steps')}</h4>
+            <List symbol="bullet">
+              {nextSteps.map(step => (
+                <ListItem key={step.name}>
+                  <ExternalLink href={step.link}>{step.name}</ExternalLink>
+                  {': '}
+                  {step.description}
+                </ListItem>
+              ))}
+            </List>
+          </Fragment>
+        )}
+      </Wrapper>
+    </AuthTokenGeneratorProvider>
   );
 }
 

+ 35 - 0
static/app/components/onboarding/gettingStartedDoc/onboardingCodeSnippet.spec.tsx

@@ -0,0 +1,35 @@
+import {replaceTokensWithSpan} from 'sentry/components/onboarding/gettingStartedDoc/onboardingCodeSnippet';
+
+describe('replaceTokenWithSpan', function () {
+  it('replaces __ORG_AUTH_TOKEN___ token', function () {
+    const element = document.createElement('div');
+    element.innerHTML =
+      '<span class="token assign-left variable">SENTRY_AUTH_TOKEN</span><span class="token operator">=</span>___ORG_AUTH_TOKEN___';
+    const tokenNodes = replaceTokensWithSpan(element);
+
+    expect(element.innerHTML).toEqual(
+      '<span class="token assign-left variable">SENTRY_AUTH_TOKEN</span><span class="token operator">=</span><span data-token="___ORG_AUTH_TOKEN___"></span>'
+    );
+    expect(tokenNodes).toHaveLength(1);
+    expect(element.contains(tokenNodes[0])).toBe(true);
+  });
+
+  it('replaces multiple ___ORG_AUTH_TOKEN___ tokens', function () {
+    const element = document.createElement('div');
+    element.innerHTML = `
+const cdn = '___ORG_AUTH_TOKEN___';
+const assetUrl = '___ORG_AUTH_TOKEN___';
+`;
+    const tokenNodes = replaceTokensWithSpan(element);
+
+    expect(element.innerHTML).toEqual(
+      `
+const cdn = '<span data-token="___ORG_AUTH_TOKEN___"></span>';
+const assetUrl = '<span data-token="___ORG_AUTH_TOKEN___"></span>';
+`
+    );
+    expect(tokenNodes).toHaveLength(2);
+    expect(element.contains(tokenNodes[0])).toBe(true);
+    expect(element.contains(tokenNodes[1])).toBe(true);
+  });
+});

+ 48 - 0
static/app/components/onboarding/gettingStartedDoc/onboardingCodeSnippet.tsx

@@ -0,0 +1,48 @@
+import {Fragment, useCallback, useState} from 'react';
+import {createPortal} from 'react-dom';
+
+import {CodeSnippet} from 'sentry/components/codeSnippet';
+import {AuthTokenGenerator} from 'sentry/components/onboarding/gettingStartedDoc/authTokenGenerator';
+import {NODE_ENV} from 'sentry/constants';
+
+interface OnboardingCodeSnippetProps
+  extends Omit<React.ComponentProps<typeof CodeSnippet>, 'onAfterHighlight'> {}
+
+/**
+ * Replaces tokens in a DOM element with a span element.
+ * @param element DOM element in which the tokens will be replaced
+ * @param tokens array of tokens to be replaced
+ * @returns object with keys as tokens and values as array of HTMLSpanElement
+ */
+export function replaceTokensWithSpan(element: HTMLElement) {
+  element.innerHTML = element.innerHTML.replace(
+    /(___ORG_AUTH_TOKEN___)/g,
+    '<span data-token="$1"></span>'
+  );
+
+  return Array.from<HTMLSpanElement>(
+    element.querySelectorAll(`[data-token="___ORG_AUTH_TOKEN___"]`)
+  );
+}
+
+/**
+ * Code snippet component that replaces `___ORG_AUTH_TOKEN___` inside snippets with AuthTokenGenerator.
+ */
+export function OnboardingCodeSnippet(props: OnboardingCodeSnippetProps) {
+  const [authTokenNodes, setAuthTokenNodes] = useState<HTMLSpanElement[]>([]);
+
+  const handleAfterHighlight = useCallback((element: HTMLElement) => {
+    // Don't execute this code in tests as it will throw an error
+    // code snippet calls the onAfterHighlight callback too late, so it triggers the "updates should be wrapped into act(...)" error
+    if (NODE_ENV !== 'test') {
+      setAuthTokenNodes(replaceTokensWithSpan(element));
+    }
+  }, []);
+
+  return (
+    <Fragment>
+      <CodeSnippet {...props} onAfterHighlight={handleAfterHighlight} />
+      {authTokenNodes.map(node => createPortal(<AuthTokenGenerator />, node))}
+    </Fragment>
+  );
+}

+ 1 - 1
static/app/components/onboarding/gettingStartedDoc/sdkDocumentation.tsx

@@ -18,12 +18,12 @@ type SdkDocumentationProps = {
 
 export type ModuleProps = {
   dsn: string;
+  projectSlug: Project['slug'];
   activeProductSelection?: ProductSolution[];
   newOrg?: boolean;
   organization?: Organization;
   platformKey?: PlatformKey;
   projectId?: Project['id'];
-  projectSlug?: Project['slug'];
   sourcePackageRegistries?: ReturnType<typeof useSourcePackageRegistries>;
 };
 

+ 5 - 5
static/app/components/onboarding/gettingStartedDoc/step.tsx

@@ -2,7 +2,7 @@ import {Fragment, useState} from 'react';
 import styled from '@emotion/styled';
 import beautify from 'js-beautify';
 
-import {CodeSnippet} from 'sentry/components/codeSnippet';
+import {OnboardingCodeSnippet} from 'sentry/components/onboarding/gettingStartedDoc/onboardingCodeSnippet';
 import {t} from 'sentry/locale';
 import {space} from 'sentry/styles/space';
 
@@ -55,7 +55,7 @@ function TabbedCodeSnippet({
   const {code, language} = selectedTab;
 
   return (
-    <CodeSnippet
+    <OnboardingCodeSnippet
       dark
       language={language}
       onCopy={onCopy}
@@ -73,7 +73,7 @@ function TabbedCodeSnippet({
             brace_style: 'preserve-inline',
           })
         : code.trim()}
-    </CodeSnippet>
+    </OnboardingCodeSnippet>
   );
 }
 
@@ -157,7 +157,7 @@ function getConfiguration({
       ) : (
         language &&
         code && (
-          <CodeSnippet
+          <OnboardingCodeSnippet
             dark
             language={language}
             onCopy={onCopy}
@@ -172,7 +172,7 @@ function getConfiguration({
                   brace_style: 'preserve-inline',
                 })
               : code.trim()}
-          </CodeSnippet>
+          </OnboardingCodeSnippet>
         )
       )}
       {additionalInfo && <AdditionalInfo>{additionalInfo}</AdditionalInfo>}

+ 1 - 1
static/app/gettingStartedDocs/android/android.spec.tsx

@@ -6,7 +6,7 @@ import {GettingStartedWithAndroid, steps} from './android';
 
 describe('GettingStartedWithAndroid', function () {
   it('renders doc correctly', function () {
-    render(<GettingStartedWithAndroid dsn="test-dsn" />);
+    render(<GettingStartedWithAndroid dsn="test-dsn" projectSlug="test-project" />);
 
     // Steps
     for (const step of steps({

+ 1 - 1
static/app/gettingStartedDocs/apple/apple-ios.spec.tsx

@@ -6,7 +6,7 @@ import {GettingStartedWithIos, steps} from './apple-ios';
 
 describe('GettingStartedWithIos', function () {
   it('renders doc correctly', function () {
-    render(<GettingStartedWithIos dsn="test-dsn" />);
+    render(<GettingStartedWithIos dsn="test-dsn" projectSlug="test-project" />);
 
     // Steps
     for (const step of steps({

+ 1 - 1
static/app/gettingStartedDocs/apple/apple-macos.spec.tsx

@@ -6,7 +6,7 @@ import {GettingStartedWithMacos, steps} from './apple-macos';
 
 describe('GettingStartedWithMacos', function () {
   it('renders doc correctly', function () {
-    render(<GettingStartedWithMacos dsn="test-dsn" />);
+    render(<GettingStartedWithMacos dsn="test-dsn" projectSlug="test-project" />);
 
     // Steps
     for (const step of steps()) {

+ 1 - 1
static/app/gettingStartedDocs/apple/apple.spec.tsx

@@ -6,7 +6,7 @@ import {GettingStartedWithApple, steps} from './apple';
 
 describe('GettingStartedWithApple', function () {
   it('renders doc correctly', function () {
-    render(<GettingStartedWithApple dsn="test-dsn" />);
+    render(<GettingStartedWithApple dsn="test-dsn" projectSlug="test-project" />);
 
     // Steps
     for (const step of steps()) {

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