]> source.dussan.org Git - sonarqube.git/commitdiff
SONAR-16263 Update onboarding token generation
authorWouter Admiraal <45544358+wouter-admiraal-sonarsource@users.noreply.github.com>
Thu, 28 Apr 2022 09:13:50 +0000 (11:13 +0200)
committersonartech <sonartech@sonarsource.com>
Fri, 29 Apr 2022 20:03:19 +0000 (20:03 +0000)
14 files changed:
server/sonar-web/src/main/js/components/tutorials/TutorialSelection.tsx
server/sonar-web/src/main/js/components/tutorials/TutorialSelectionRenderer.tsx
server/sonar-web/src/main/js/components/tutorials/__tests__/TutorialSelection-test.tsx
server/sonar-web/src/main/js/components/tutorials/__tests__/TutorialSelectionRenderer-test.tsx
server/sonar-web/src/main/js/components/tutorials/__tests__/__snapshots__/TutorialSelection-test.tsx.snap
server/sonar-web/src/main/js/components/tutorials/__tests__/__snapshots__/TutorialSelectionRenderer-test.tsx.snap
server/sonar-web/src/main/js/components/tutorials/components/EditTokenModal.tsx
server/sonar-web/src/main/js/components/tutorials/components/__tests__/__snapshots__/EditTokenModal-test.tsx.snap
server/sonar-web/src/main/js/components/tutorials/manual/ManualTutorial.tsx
server/sonar-web/src/main/js/components/tutorials/manual/TokenStep.tsx
server/sonar-web/src/main/js/components/tutorials/manual/__tests__/TokenStep-test.tsx
server/sonar-web/src/main/js/components/tutorials/manual/__tests__/__snapshots__/ManualTutorial-test.tsx.snap
server/sonar-web/src/main/js/components/tutorials/manual/__tests__/__snapshots__/TokenStep-test.tsx.snap
sonar-core/src/main/resources/org/sonar/l10n/core.properties

index b8cea65d8a0c8f3538041e8611a18635b26b3d79..beaccfa713e5563675250e923fd83c66d25a7cb0 100644 (file)
 import * as React from 'react';
 import { WithRouterProps } from 'react-router';
 import { getAlmSettingsNoCatch } from '../../api/alm-settings';
+import { getScannableProjects } from '../../api/components';
 import { getValues } from '../../api/settings';
 import { getHostUrl } from '../../helpers/urls';
+import { hasGlobalPermission } from '../../helpers/users';
 import { AlmSettingsInstance, ProjectAlmBindingResponse } from '../../types/alm-settings';
+import { Permissions } from '../../types/permissions';
 import { SettingsKey } from '../../types/settings';
 import { Component } from '../../types/types';
 import { LoggedInUser } from '../../types/users';
@@ -39,6 +42,7 @@ interface Props extends Pick<WithRouterProps, 'router' | 'location'> {
 
 interface State {
   almBinding?: AlmSettingsInstance;
+  currentUserCanScanProject: boolean;
   baseUrl: string;
   loading: boolean;
 }
@@ -46,6 +50,7 @@ interface State {
 export class TutorialSelection extends React.PureComponent<Props, State> {
   mounted = false;
   state: State = {
+    currentUserCanScanProject: false,
     baseUrl: getHostUrl(),
     loading: true
   };
@@ -53,7 +58,7 @@ export class TutorialSelection extends React.PureComponent<Props, State> {
   async componentDidMount() {
     this.mounted = true;
 
-    await Promise.all([this.fetchAlmBindings(), this.fetchBaseUrl()]);
+    await Promise.all([this.fetchAlmBindings(), this.fetchBaseUrl(), this.checkUserPermissions()]);
 
     if (this.mounted) {
       this.setState({ loading: false });
@@ -64,6 +69,22 @@ export class TutorialSelection extends React.PureComponent<Props, State> {
     this.mounted = false;
   }
 
+  checkUserPermissions = async () => {
+    const { component, currentUser } = this.props;
+
+    if (hasGlobalPermission(currentUser, Permissions.Scan)) {
+      this.setState({ currentUserCanScanProject: true });
+      return Promise.resolve();
+    }
+
+    const { projects } = await getScannableProjects();
+    this.setState({
+      currentUserCanScanProject: projects.find(p => p.key === component.key) !== undefined
+    });
+
+    return Promise.resolve();
+  };
+
   fetchAlmBindings = async () => {
     const { component, projectBinding } = this.props;
 
@@ -107,7 +128,7 @@ export class TutorialSelection extends React.PureComponent<Props, State> {
       projectBinding,
       willRefreshAutomatically
     } = this.props;
-    const { almBinding, baseUrl, loading } = this.state;
+    const { almBinding, baseUrl, currentUserCanScanProject, loading } = this.state;
 
     const selectedTutorial: TutorialModes | undefined = location.query?.selectedTutorial;
 
@@ -117,6 +138,7 @@ export class TutorialSelection extends React.PureComponent<Props, State> {
         baseUrl={baseUrl}
         component={component}
         currentUser={currentUser}
+        currentUserCanScanProject={currentUserCanScanProject}
         loading={loading}
         onSelectTutorial={this.handleSelectTutorial}
         projectBinding={projectBinding}
index bf261c639f34bbe94e36f395a25195016f93f370..b0302f3d681fc1b5c13273c8d6fd1523178dabf8 100644 (file)
@@ -24,6 +24,7 @@ import { getBaseUrl } from '../../helpers/system';
 import { AlmKeys, AlmSettingsInstance, ProjectAlmBindingResponse } from '../../types/alm-settings';
 import { Component } from '../../types/types';
 import { LoggedInUser } from '../../types/users';
+import { Alert } from '../ui/Alert';
 import AzurePipelinesTutorial from './azure-pipelines/AzurePipelinesTutorial';
 import BitbucketPipelinesTutorial from './bitbucket-pipelines/BitbucketPipelinesTutorial';
 import GitHubActionTutorial from './github-action/GitHubActionTutorial';
@@ -37,6 +38,7 @@ export interface TutorialSelectionRendererProps {
   baseUrl: string;
   component: Component;
   currentUser: LoggedInUser;
+  currentUserCanScanProject: boolean;
   loading: boolean;
   onSelectTutorial: (mode: TutorialModes) => void;
   projectBinding?: ProjectAlmBindingResponse;
@@ -73,6 +75,7 @@ export default function TutorialSelectionRenderer(props: TutorialSelectionRender
     baseUrl,
     component,
     currentUser,
+    currentUserCanScanProject,
     loading,
     projectBinding,
     selectedTutorial,
@@ -83,6 +86,10 @@ export default function TutorialSelectionRenderer(props: TutorialSelectionRender
     return <i className="spinner" />;
   }
 
+  if (!currentUserCanScanProject) {
+    return <Alert variant="warning">{translate('onboarding.tutorial.no_scan_rights')}</Alert>;
+  }
+
   let showGitHubActions = true;
   let showGitLabCICD = true;
   let showBitbucketPipelines = true;
index aa83fbccd1d23b474cccee37b31b2ead830a1e6a..5e3e0c73a7929d070c2783469a5d61c52c9ae733 100644 (file)
@@ -20,6 +20,7 @@
 import { shallow } from 'enzyme';
 import * as React from 'react';
 import { getAlmSettingsNoCatch } from '../../../api/alm-settings';
+import { getScannableProjects } from '../../../api/components';
 import { getValues } from '../../../api/settings';
 import {
   mockAlmSettingsInstance,
@@ -29,6 +30,7 @@ import { mockComponent } from '../../../helpers/mocks/component';
 import { mockLocation, mockLoggedInUser, mockRouter } from '../../../helpers/testMocks';
 import { waitAndUpdate } from '../../../helpers/testUtils';
 import { getHostUrl } from '../../../helpers/urls';
+import { Permissions } from '../../../types/permissions';
 import { SettingsKey } from '../../../types/settings';
 import { TutorialSelection } from '../TutorialSelection';
 import { TutorialModes } from '../types';
@@ -45,6 +47,10 @@ jest.mock('../../../api/settings', () => ({
   getValues: jest.fn().mockResolvedValue([])
 }));
 
+jest.mock('../../../api/components', () => ({
+  getScannableProjects: jest.fn().mockResolvedValue({ projects: [] })
+}));
+
 beforeEach(jest.clearAllMocks);
 
 it('should render correctly', () => {
@@ -109,6 +115,39 @@ it('should fetch the correct baseUrl', async () => {
   expect(wrapper.state().baseUrl).toBe('http://host.url');
 });
 
+it("should correctly determine the user's permission", async () => {
+  const component = mockComponent({ key: 'bar', name: 'Bar' });
+  (getScannableProjects as jest.Mock)
+    .mockResolvedValueOnce({
+      projects: [
+        { key: 'foo', name: 'Foo' },
+        { key: component.key, name: component.name }
+      ]
+    })
+    .mockResolvedValueOnce({ projects: [{ key: 'foo', name: 'Foo' }] });
+
+  // Global scan permission.
+  let wrapper = shallowRender({
+    component,
+    currentUser: mockLoggedInUser({ permissions: { global: [Permissions.Scan] } })
+  });
+  await waitAndUpdate(wrapper);
+  expect(wrapper.state().currentUserCanScanProject).toBe(true);
+  expect(getScannableProjects).not.toBeCalled();
+
+  // Project scan permission.
+  wrapper = shallowRender({ component });
+  await waitAndUpdate(wrapper);
+  expect(getScannableProjects).toBeCalled();
+  expect(wrapper.state().currentUserCanScanProject).toBe(true);
+
+  // No scan permission.
+  wrapper = shallowRender({ component });
+  await waitAndUpdate(wrapper);
+  expect(getScannableProjects).toBeCalledTimes(2);
+  expect(wrapper.state().currentUserCanScanProject).toBe(false);
+});
+
 function shallowRender(props: Partial<TutorialSelection['props']> = {}) {
   return shallow<TutorialSelection>(
     <TutorialSelection
index 1c34353036b80b6e5a101bf1f84bd705acb03423..6f2839a61dabd5a5756e8a80fc9dc0cbb2f918ee 100644 (file)
@@ -74,6 +74,9 @@ it('should render correctly', () => {
       projectBinding: mockProjectAzureBindingResponse()
     })
   ).toMatchSnapshot('azure pipelines tutorial');
+  expect(shallowRender({ currentUserCanScanProject: false })).toMatchSnapshot(
+    'user has no scan permission'
+  );
 });
 
 it('should allow mode selection for Bitbucket', () => {
@@ -165,6 +168,7 @@ function shallowRender(props: Partial<TutorialSelectionRendererProps> = {}) {
       baseUrl="http://localhost:9000"
       component={mockComponent()}
       currentUser={mockLoggedInUser()}
+      currentUserCanScanProject={true}
       loading={false}
       onSelectTutorial={jest.fn()}
       {...props}
index d07227362072df664eb4628ff411e299306f8e1b..0770dd01df86d4cd406b25f80701b04361184e7d 100644 (file)
@@ -34,6 +34,7 @@ exports[`should render correctly 1`] = `
       "scmAccounts": Array [],
     }
   }
+  currentUserCanScanProject={false}
   loading={true}
   onSelectTutorial={[Function]}
 />
index 0e4c0c77b8a2e006139f4bbd3df2f7e9561564c4..5d03103db9514e02cfc17865e93490e641f5effe 100644 (file)
@@ -714,3 +714,11 @@ exports[`should render correctly: selection 1`] = `
   </div>
 </Fragment>
 `;
+
+exports[`should render correctly: user has no scan permission 1`] = `
+<Alert
+  variant="warning"
+>
+  onboarding.tutorial.no_scan_rights
+</Alert>
+`;
index 77d8e3497008162407d1035d2be7f69b6bb83e8e..9d5774dd572aa62f6b4ba7f7888a8d5a8085e884 100644 (file)
@@ -27,6 +27,7 @@ import SimpleModal from '../../../components/controls/SimpleModal';
 import { Alert } from '../../../components/ui/Alert';
 import DeferredSpinner from '../../../components/ui/DeferredSpinner';
 import { translate, translateWithParameters } from '../../../helpers/l10n';
+import { TokenType } from '../../../types/token';
 import { Component } from '../../../types/types';
 import { LoggedInUser } from '../../../types/users';
 import { getUniqueTokenName } from '../utils';
@@ -73,9 +74,16 @@ export default class EditTokenModal extends React.PureComponent<Props, State> {
   };
 
   getNewToken = async () => {
+    const {
+      component: { key }
+    } = this.props;
     const { tokenName } = this.state;
 
-    const { token } = await generateToken({ name: tokenName });
+    const { token } = await generateToken({
+      name: tokenName,
+      type: TokenType.Project,
+      projectKey: key
+    });
 
     if (this.mounted) {
       this.setState({
@@ -109,7 +117,7 @@ export default class EditTokenModal extends React.PureComponent<Props, State> {
   render() {
     const { loading, token, tokenName } = this.state;
 
-    const header = translate('onboarding.token.generate_token');
+    const header = translate('onboarding.token.generate_project_token');
 
     return (
       <SimpleModal header={header} onClose={this.props.onClose} onSubmit={this.props.onClose}>
@@ -122,8 +130,8 @@ export default class EditTokenModal extends React.PureComponent<Props, State> {
             <div className="modal-body">
               <p className="spacer-bottom">
                 <FormattedMessage
-                  defaultMessage={translate('onboarding.token.text')}
-                  id="onboarding.token.text"
+                  defaultMessage={translate('onboarding.project_token.text')}
+                  id="onboarding.project_token.text"
                   values={{
                     link: (
                       <Link target="_blank" to="/account/security">
index 6be1acf254401f28b0c998bd31698b096679c892..98ffd844af507fad8bd5e48a55a3b52cdd360301 100644 (file)
@@ -2,7 +2,7 @@
 
 exports[`should render correctly 1`] = `
 <SimpleModal
-  header="onboarding.token.generate_token"
+  header="onboarding.token.generate_project_token"
   onClose={[MockFunction]}
   onSubmit={[MockFunction]}
 >
index 5e3f818714d151463125579f0ad262534789121e..42254d34eae734e6b3c14e3737062803889d53d5 100644 (file)
@@ -66,6 +66,7 @@ export default class ManualTutorial extends React.PureComponent<Props, State> {
 
         <TokenStep
           currentUser={currentUser}
+          projectKey={component.key}
           finished={Boolean(this.state.token)}
           initialTokenName={`Analyze "${component.name}"`}
           onContinue={this.handleTokenDone}
index ae7bd3fd9680c8b01d71cd0d14b033850710e879..b3ab6871a826b64158996bfa007fe20eb44217f2 100644 (file)
@@ -25,7 +25,7 @@ import { Button, DeleteButton, SubmitButton } from '../../../components/controls
 import Radio from '../../../components/controls/Radio';
 import AlertSuccessIcon from '../../../components/icons/AlertSuccessIcon';
 import { translate } from '../../../helpers/l10n';
-import { UserToken } from '../../../types/token';
+import { TokenType, UserToken } from '../../../types/token';
 import { LoggedInUser } from '../../../types/users';
 import AlertErrorIcon from '../../icons/AlertErrorIcon';
 import Step from '../components/Step';
@@ -33,6 +33,7 @@ import { getUniqueTokenName } from '../utils';
 
 interface Props {
   currentUser: Pick<LoggedInUser, 'login'>;
+  projectKey: string;
   finished: boolean;
   initialTokenName?: string;
   open: boolean;
@@ -101,16 +102,25 @@ export default class TokenStep extends React.PureComponent<Props, State> {
     this.setState({ tokenName: event.target.value });
   };
 
-  handleTokenGenerate = (event: React.FormEvent<HTMLFormElement>) => {
+  handleTokenGenerate = async (event: React.FormEvent<HTMLFormElement>) => {
     event.preventDefault();
     const { tokenName } = this.state;
+    const { projectKey } = this.props;
+
     if (tokenName) {
       this.setState({ loading: true });
-      generateToken({ name: tokenName }).then(({ token }) => {
+      try {
+        const { token } = await generateToken({
+          name: tokenName,
+          type: TokenType.Project,
+          projectKey
+        });
         if (this.mounted) {
           this.setState({ loading: false, token });
         }
-      }, this.stopLoading);
+      } catch (e) {
+        this.stopLoading();
+      }
     }
   };
 
@@ -164,10 +174,10 @@ export default class TokenStep extends React.PureComponent<Props, State> {
           checked={this.state.selection === 'generate'}
           onCheck={this.handleModeChange}
           value="generate">
-          {translate('onboarding.token.generate_token')}
+          {translate('onboarding.token.generate_project_token')}
         </Radio>
       ) : (
-        translate('onboarding.token.generate_token')
+        translate('onboarding.token.generate_project_token')
       )}
       {this.state.selection === 'generate' && (
         <div className="big-spacer-top">
@@ -176,7 +186,7 @@ export default class TokenStep extends React.PureComponent<Props, State> {
               autoFocus={true}
               className="input-super-large spacer-right text-middle"
               onChange={this.handleTokenNameChange}
-              placeholder={translate('onboarding.token.generate_token.placeholder')}
+              placeholder={translate('onboarding.token.generate_project_token.placeholder')}
               required={true}
               type="text"
               value={this.state.tokenName || ''}
index 3ecf8bb1eb74ea5843a2a1df5eefefe51b90cbd2..ae3ca36c353b76228333579d2dbd5008b71d4396 100644 (file)
@@ -19,8 +19,8 @@
  */
 import { shallow } from 'enzyme';
 import * as React from 'react';
+import { mockLoggedInUser } from '../../../../helpers/testMocks';
 import { change, click, submit, waitAndUpdate } from '../../../../helpers/testUtils';
-import { LoggedInUser } from '../../../../types/users';
 import TokenStep from '../TokenStep';
 
 jest.mock('../../../../api/user-tokens', () => ({
@@ -29,19 +29,8 @@ jest.mock('../../../../api/user-tokens', () => ({
   revokeToken: () => Promise.resolve()
 }));
 
-const currentUser: Pick<LoggedInUser, 'login'> = { login: 'user' };
-
 it('generates token', async () => {
-  const wrapper = shallow(
-    <TokenStep
-      currentUser={currentUser}
-      finished={false}
-      onContinue={jest.fn()}
-      onOpen={jest.fn()}
-      open={true}
-      stepNumber={1}
-    />
-  );
+  const wrapper = shallowRender();
   await waitAndUpdate(wrapper);
   expect(wrapper.dive()).toMatchSnapshot();
   change(wrapper.dive().find('input'), 'my token');
@@ -52,16 +41,7 @@ it('generates token', async () => {
 });
 
 it('revokes token', async () => {
-  const wrapper = shallow(
-    <TokenStep
-      currentUser={currentUser}
-      finished={false}
-      onContinue={jest.fn()}
-      onOpen={jest.fn()}
-      open={true}
-      stepNumber={1}
-    />
-  );
+  const wrapper = shallowRender();
   await new Promise(setImmediate);
   wrapper.setState({ token: 'abcd1234', tokenName: 'my token' });
   expect(wrapper.dive()).toMatchSnapshot();
@@ -77,16 +57,7 @@ it('revokes token', async () => {
 
 it('continues', async () => {
   const onContinue = jest.fn();
-  const wrapper = shallow(
-    <TokenStep
-      currentUser={currentUser}
-      finished={false}
-      onContinue={onContinue}
-      onOpen={jest.fn()}
-      open={true}
-      stepNumber={1}
-    />
-  );
+  const wrapper = shallowRender({ onContinue });
   await new Promise(setImmediate);
   wrapper.setState({ token: 'abcd1234', tokenName: 'my token' });
   click(wrapper.dive().find('[className="js-continue"]'));
@@ -95,18 +66,24 @@ it('continues', async () => {
 
 it('uses existing token', async () => {
   const onContinue = jest.fn();
-  const wrapper = shallow(
+  const wrapper = shallowRender({ onContinue });
+  await new Promise(setImmediate);
+  wrapper.setState({ existingToken: 'abcd1234', selection: 'use-existing' });
+  click(wrapper.dive().find('[className="js-continue"]'));
+  expect(onContinue).toBeCalledWith('abcd1234');
+});
+
+function shallowRender(props: Partial<TokenStep['props']> = {}) {
+  return shallow<TokenStep>(
     <TokenStep
-      currentUser={currentUser}
+      currentUser={mockLoggedInUser({ login: 'user' })}
       finished={false}
-      onContinue={onContinue}
+      onContinue={jest.fn()}
       onOpen={jest.fn()}
       open={true}
+      projectKey="foo"
       stepNumber={1}
+      {...props}
     />
   );
-  await new Promise(setImmediate);
-  wrapper.setState({ existingToken: 'abcd1234', selection: 'use-existing' });
-  click(wrapper.dive().find('[className="js-continue"]'));
-  expect(onContinue).toBeCalledWith('abcd1234');
-});
+}
index 0bab14da2f22ee82b86c30389383455e03be2daf..4a4af90decaa45e7f704435de8e71499f880cad1 100644 (file)
@@ -33,6 +33,7 @@ exports[`renders correctly: default 1`] = `
     onContinue={[Function]}
     onOpen={[Function]}
     open={true}
+    projectKey="my-project"
     stepNumber={1}
   />
   <ProjectAnalysisStep
index 6ad2bfc2e0c0bbbd5c1e1f847c1c0fca5506eefd..e2e79d0cdb538d0e367f5fe4ca922b8783619ff8 100644 (file)
@@ -29,7 +29,7 @@ exports[`generates token 1`] = `
             onCheck={[Function]}
             value="generate"
           >
-            onboarding.token.generate_token
+            onboarding.token.generate_project_token
           </Radio>
           <div
             className="big-spacer-top"
@@ -41,7 +41,7 @@ exports[`generates token 1`] = `
                 autoFocus={true}
                 className="input-super-large spacer-right text-middle"
                 onChange={[Function]}
-                placeholder="onboarding.token.generate_token.placeholder"
+                placeholder="onboarding.token.generate_project_token.placeholder"
                 required={true}
                 type="text"
                 value=""
@@ -121,7 +121,7 @@ exports[`generates token 2`] = `
             onCheck={[Function]}
             value="generate"
           >
-            onboarding.token.generate_token
+            onboarding.token.generate_project_token
           </Radio>
           <div
             className="big-spacer-top"
@@ -133,7 +133,7 @@ exports[`generates token 2`] = `
                 autoFocus={true}
                 className="input-super-large spacer-right text-middle"
                 onChange={[Function]}
-                placeholder="onboarding.token.generate_token.placeholder"
+                placeholder="onboarding.token.generate_project_token.placeholder"
                 required={true}
                 type="text"
                 value="my token"
@@ -437,7 +437,7 @@ exports[`revokes token 3`] = `
             onCheck={[Function]}
             value="generate"
           >
-            onboarding.token.generate_token
+            onboarding.token.generate_project_token
           </Radio>
           <div
             className="big-spacer-top"
@@ -449,7 +449,7 @@ exports[`revokes token 3`] = `
                 autoFocus={true}
                 className="input-super-large spacer-right text-middle"
                 onChange={[Function]}
-                placeholder="onboarding.token.generate_token.placeholder"
+                placeholder="onboarding.token.generate_project_token.placeholder"
                 required={true}
                 type="text"
                 value=""
index 8dabb668e17f66432fe2f7b2a5926e0d33b8ffd9..bf0355599b5580cce047c95404186150a42e2ef1 100644 (file)
@@ -3441,12 +3441,15 @@ onboarding.create_project.gitlab.no_projects=No projects could be fetched from G
 onboarding.create_project.gitlab.link=See on GitLab
 
 onboarding.token.header=Provide a token
-onboarding.token.text=The token is used to identify you when an analysis is performed. If it has been compromised, you can revoke it at any point of time in your {link}.
+onboarding.token.text=The token is used to identify you when an analysis is performed. If it has been compromised, you can revoke it at any point in time in your {link}.
+onboarding.project_token.text=The project token is used to identify you when an analysis is performed. If it has been compromised, you can revoke it at any point in time in your {link}.
 onboarding.token.text.user_account=user account
 onboarding.token.generate=Generate
 onboarding.token.placeholder=Enter a name for your token
 onboarding.token.generate_token=Generate a token
 onboarding.token.generate_token.placeholder=Enter a name for your token
+onboarding.token.generate_project_token=Generate a project token
+onboarding.token.generate_project_token.placeholder=Enter a name for your project token
 onboarding.token.use_existing_token=Use existing token
 onboarding.token.use_existing_token.placeholder=Enter your existing token
 onboarding.token.use_existing_token.label=Existing token value
@@ -3946,6 +3949,8 @@ onboarding.tutorial.with.azure_pipelines.BranchAnalysis.continous_integration.no
 onboarding.tutorial.with.azure_pipelines.BranchAnalysis.continous_integration.no_branches.sentence.continuous_integration=Enable continuous integration
 onboarding.tutorial.with.azure_pipelines.BranchAnalysis.branch_protection=To make sure your Pull Requests are analyzed automatically and aren't merged when they're failing their quality gate, check out the {link}.
 onboarding.tutorial.with.azure_pipelines.BranchAnalysis.branch_protection.link=documentation
+
+onboarding.tutorial.no_scan_rights=You do not have permission to analyze this project. Please contact the project administrator.
 #------------------------------------------------------------------------------
 #
 # BRANCHES