From 445764544cfc10e3b157a4337caed91ec684217e Mon Sep 17 00:00:00 2001 From: Wouter Admiraal <45544358+wouter-admiraal-sonarsource@users.noreply.github.com> Date: Thu, 28 Apr 2022 11:13:50 +0200 Subject: [PATCH] SONAR-16263 Update onboarding token generation --- .../tutorials/TutorialSelection.tsx | 26 +++++++- .../tutorials/TutorialSelectionRenderer.tsx | 7 +++ .../__tests__/TutorialSelection-test.tsx | 39 ++++++++++++ .../TutorialSelectionRenderer-test.tsx | 4 ++ .../TutorialSelection-test.tsx.snap | 1 + .../TutorialSelectionRenderer-test.tsx.snap | 8 +++ .../tutorials/components/EditTokenModal.tsx | 16 +++-- .../EditTokenModal-test.tsx.snap | 2 +- .../tutorials/manual/ManualTutorial.tsx | 1 + .../components/tutorials/manual/TokenStep.tsx | 24 +++++--- .../manual/__tests__/TokenStep-test.tsx | 59 ++++++------------- .../ManualTutorial-test.tsx.snap | 1 + .../__snapshots__/TokenStep-test.tsx.snap | 12 ++-- .../resources/org/sonar/l10n/core.properties | 7 ++- 14 files changed, 145 insertions(+), 62 deletions(-) diff --git a/server/sonar-web/src/main/js/components/tutorials/TutorialSelection.tsx b/server/sonar-web/src/main/js/components/tutorials/TutorialSelection.tsx index b8cea65d8a0..beaccfa713e 100644 --- a/server/sonar-web/src/main/js/components/tutorials/TutorialSelection.tsx +++ b/server/sonar-web/src/main/js/components/tutorials/TutorialSelection.tsx @@ -20,9 +20,12 @@ 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 { interface State { almBinding?: AlmSettingsInstance; + currentUserCanScanProject: boolean; baseUrl: string; loading: boolean; } @@ -46,6 +50,7 @@ interface State { export class TutorialSelection extends React.PureComponent { mounted = false; state: State = { + currentUserCanScanProject: false, baseUrl: getHostUrl(), loading: true }; @@ -53,7 +58,7 @@ export class TutorialSelection extends React.PureComponent { 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 { 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 { 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 { baseUrl={baseUrl} component={component} currentUser={currentUser} + currentUserCanScanProject={currentUserCanScanProject} loading={loading} onSelectTutorial={this.handleSelectTutorial} projectBinding={projectBinding} diff --git a/server/sonar-web/src/main/js/components/tutorials/TutorialSelectionRenderer.tsx b/server/sonar-web/src/main/js/components/tutorials/TutorialSelectionRenderer.tsx index bf261c639f3..b0302f3d681 100644 --- a/server/sonar-web/src/main/js/components/tutorials/TutorialSelectionRenderer.tsx +++ b/server/sonar-web/src/main/js/components/tutorials/TutorialSelectionRenderer.tsx @@ -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 ; } + if (!currentUserCanScanProject) { + return {translate('onboarding.tutorial.no_scan_rights')}; + } + let showGitHubActions = true; let showGitLabCICD = true; let showBitbucketPipelines = true; diff --git a/server/sonar-web/src/main/js/components/tutorials/__tests__/TutorialSelection-test.tsx b/server/sonar-web/src/main/js/components/tutorials/__tests__/TutorialSelection-test.tsx index aa83fbccd1d..5e3e0c73a79 100644 --- a/server/sonar-web/src/main/js/components/tutorials/__tests__/TutorialSelection-test.tsx +++ b/server/sonar-web/src/main/js/components/tutorials/__tests__/TutorialSelection-test.tsx @@ -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 = {}) { return shallow( { 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 = {}) { baseUrl="http://localhost:9000" component={mockComponent()} currentUser={mockLoggedInUser()} + currentUserCanScanProject={true} loading={false} onSelectTutorial={jest.fn()} {...props} diff --git a/server/sonar-web/src/main/js/components/tutorials/__tests__/__snapshots__/TutorialSelection-test.tsx.snap b/server/sonar-web/src/main/js/components/tutorials/__tests__/__snapshots__/TutorialSelection-test.tsx.snap index d0722736207..0770dd01df8 100644 --- a/server/sonar-web/src/main/js/components/tutorials/__tests__/__snapshots__/TutorialSelection-test.tsx.snap +++ b/server/sonar-web/src/main/js/components/tutorials/__tests__/__snapshots__/TutorialSelection-test.tsx.snap @@ -34,6 +34,7 @@ exports[`should render correctly 1`] = ` "scmAccounts": Array [], } } + currentUserCanScanProject={false} loading={true} onSelectTutorial={[Function]} /> diff --git a/server/sonar-web/src/main/js/components/tutorials/__tests__/__snapshots__/TutorialSelectionRenderer-test.tsx.snap b/server/sonar-web/src/main/js/components/tutorials/__tests__/__snapshots__/TutorialSelectionRenderer-test.tsx.snap index 0e4c0c77b8a..5d03103db95 100644 --- a/server/sonar-web/src/main/js/components/tutorials/__tests__/__snapshots__/TutorialSelectionRenderer-test.tsx.snap +++ b/server/sonar-web/src/main/js/components/tutorials/__tests__/__snapshots__/TutorialSelectionRenderer-test.tsx.snap @@ -714,3 +714,11 @@ exports[`should render correctly: selection 1`] = ` `; + +exports[`should render correctly: user has no scan permission 1`] = ` + + onboarding.tutorial.no_scan_rights + +`; diff --git a/server/sonar-web/src/main/js/components/tutorials/components/EditTokenModal.tsx b/server/sonar-web/src/main/js/components/tutorials/components/EditTokenModal.tsx index 77d8e349700..9d5774dd572 100644 --- a/server/sonar-web/src/main/js/components/tutorials/components/EditTokenModal.tsx +++ b/server/sonar-web/src/main/js/components/tutorials/components/EditTokenModal.tsx @@ -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 { }; 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 { render() { const { loading, token, tokenName } = this.state; - const header = translate('onboarding.token.generate_token'); + const header = translate('onboarding.token.generate_project_token'); return ( @@ -122,8 +130,8 @@ export default class EditTokenModal extends React.PureComponent {

diff --git a/server/sonar-web/src/main/js/components/tutorials/components/__tests__/__snapshots__/EditTokenModal-test.tsx.snap b/server/sonar-web/src/main/js/components/tutorials/components/__tests__/__snapshots__/EditTokenModal-test.tsx.snap index 6be1acf2544..98ffd844af5 100644 --- a/server/sonar-web/src/main/js/components/tutorials/components/__tests__/__snapshots__/EditTokenModal-test.tsx.snap +++ b/server/sonar-web/src/main/js/components/tutorials/components/__tests__/__snapshots__/EditTokenModal-test.tsx.snap @@ -2,7 +2,7 @@ exports[`should render correctly 1`] = ` diff --git a/server/sonar-web/src/main/js/components/tutorials/manual/ManualTutorial.tsx b/server/sonar-web/src/main/js/components/tutorials/manual/ManualTutorial.tsx index 5e3f818714d..42254d34eae 100644 --- a/server/sonar-web/src/main/js/components/tutorials/manual/ManualTutorial.tsx +++ b/server/sonar-web/src/main/js/components/tutorials/manual/ManualTutorial.tsx @@ -66,6 +66,7 @@ export default class ManualTutorial extends React.PureComponent { ; + projectKey: string; finished: boolean; initialTokenName?: string; open: boolean; @@ -101,16 +102,25 @@ export default class TokenStep extends React.PureComponent { this.setState({ tokenName: event.target.value }); }; - handleTokenGenerate = (event: React.FormEvent) => { + handleTokenGenerate = async (event: React.FormEvent) => { 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 { checked={this.state.selection === 'generate'} onCheck={this.handleModeChange} value="generate"> - {translate('onboarding.token.generate_token')} + {translate('onboarding.token.generate_project_token')} ) : ( - translate('onboarding.token.generate_token') + translate('onboarding.token.generate_project_token') )} {this.state.selection === 'generate' && (

@@ -176,7 +186,7 @@ export default class TokenStep extends React.PureComponent { 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 || ''} diff --git a/server/sonar-web/src/main/js/components/tutorials/manual/__tests__/TokenStep-test.tsx b/server/sonar-web/src/main/js/components/tutorials/manual/__tests__/TokenStep-test.tsx index 3ecf8bb1eb7..ae3ca36c353 100644 --- a/server/sonar-web/src/main/js/components/tutorials/manual/__tests__/TokenStep-test.tsx +++ b/server/sonar-web/src/main/js/components/tutorials/manual/__tests__/TokenStep-test.tsx @@ -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 = { login: 'user' }; - it('generates token', async () => { - const wrapper = shallow( - - ); + 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( - - ); + 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( - - ); + 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 = {}) { + return shallow( ); - await new Promise(setImmediate); - wrapper.setState({ existingToken: 'abcd1234', selection: 'use-existing' }); - click(wrapper.dive().find('[className="js-continue"]')); - expect(onContinue).toBeCalledWith('abcd1234'); -}); +} diff --git a/server/sonar-web/src/main/js/components/tutorials/manual/__tests__/__snapshots__/ManualTutorial-test.tsx.snap b/server/sonar-web/src/main/js/components/tutorials/manual/__tests__/__snapshots__/ManualTutorial-test.tsx.snap index 0bab14da2f2..4a4af90deca 100644 --- a/server/sonar-web/src/main/js/components/tutorials/manual/__tests__/__snapshots__/ManualTutorial-test.tsx.snap +++ b/server/sonar-web/src/main/js/components/tutorials/manual/__tests__/__snapshots__/ManualTutorial-test.tsx.snap @@ -33,6 +33,7 @@ exports[`renders correctly: default 1`] = ` onContinue={[Function]} onOpen={[Function]} open={true} + projectKey="my-project" stepNumber={1} /> - onboarding.token.generate_token + onboarding.token.generate_project_token
- onboarding.token.generate_token + onboarding.token.generate_project_token
- onboarding.token.generate_token + onboarding.token.generate_project_token