@@ -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<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} |
@@ -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; |
@@ -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 |
@@ -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} |
@@ -34,6 +34,7 @@ exports[`should render correctly 1`] = ` | |||
"scmAccounts": Array [], | |||
} | |||
} | |||
currentUserCanScanProject={false} | |||
loading={true} | |||
onSelectTutorial={[Function]} | |||
/> |
@@ -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> | |||
`; |
@@ -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"> |
@@ -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]} | |||
> |
@@ -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} |
@@ -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 || ''} |
@@ -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'); | |||
}); | |||
} |
@@ -33,6 +33,7 @@ exports[`renders correctly: default 1`] = ` | |||
onContinue={[Function]} | |||
onOpen={[Function]} | |||
open={true} | |||
projectKey="my-project" | |||
stepNumber={1} | |||
/> | |||
<ProjectAnalysisStep |
@@ -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="" |
@@ -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 |