@@ -19,22 +19,23 @@ | |||
*/ | |||
import { cloneDeep } from 'lodash'; | |||
import { NewUserToken, UserToken } from '../../types/token'; | |||
import { mockUserToken } from '../../helpers/mocks/token'; | |||
import { NewUserToken, TokenType, UserToken } from '../../types/token'; | |||
import { generateToken, getTokens, revokeToken } from '../user-tokens'; | |||
const RANDOM_RADIX = 36; | |||
const RANDOM_PREFIX = 2; | |||
const defaultTokens = [ | |||
{ | |||
mockUserToken({ | |||
name: 'local-scanner', | |||
createdAt: '2022-03-07T09:02:59+0000', | |||
lastConnectionDate: '2022-04-07T09:51:48+0000' | |||
}, | |||
{ | |||
}), | |||
mockUserToken({ | |||
name: 'test', | |||
createdAt: '2020-01-23T19:25:19+0000' | |||
} | |||
}) | |||
]; | |||
export default class UserTokensMock { | |||
@@ -52,10 +53,22 @@ export default class UserTokensMock { | |||
return Promise.resolve(cloneDeep(this.tokens)); | |||
}; | |||
handleGenerateToken = ({ name, login }: { name: string; login?: string }) => { | |||
handleGenerateToken = ({ | |||
name, | |||
login, | |||
type, | |||
projectKey | |||
}: { | |||
name: string; | |||
login?: string; | |||
type: TokenType; | |||
projectKey: string; | |||
}) => { | |||
const token = { | |||
name, | |||
login, | |||
type, | |||
projectKey, | |||
token: Math.random() | |||
.toString(RANDOM_RADIX) | |||
.slice(RANDOM_PREFIX), |
@@ -235,6 +235,7 @@ describe('security page', () => { | |||
expect(generateButton).toBeDisabled(); | |||
const tokenTypeLabel = `users.tokens.${tokenTypeOption}`; | |||
const tokenTypeShortLabel = `users.tokens.${tokenTypeOption}.short`; | |||
if (tokenTypeOption === TokenType.Project) { | |||
await selectEvent.select(screen.getAllByRole('textbox')[1], [tokenTypeLabel]); | |||
@@ -260,11 +261,17 @@ describe('security page', () => { | |||
expect(screen.getAllByRole('row')).toHaveLength(4); // 3 tokens + header | |||
// Revoke the token | |||
const row = screen.getAllByRole('row', { | |||
name: (n: string) => n.includes(newTokenName) | |||
const row = screen.getByRole('row', { | |||
name: new RegExp(`^${newTokenName}`) | |||
}); | |||
const revokeButtons = within(row[0]).getByRole('button', { | |||
expect(await within(row).findByText(tokenTypeShortLabel)).toBeInTheDocument(); | |||
if (tokenTypeOption === TokenType.Project) { | |||
expect(await within(row).findByText('Project Name 1')).toBeInTheDocument(); | |||
} | |||
// Revoke the token | |||
const revokeButtons = within(row).getByRole('button', { | |||
name: 'users.tokens.revoke_token' | |||
}); | |||
await user.click(revokeButtons); |
@@ -18,7 +18,7 @@ | |||
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. | |||
*/ | |||
.account-container { | |||
width: 700px; | |||
width: 800px; | |||
margin-left: auto; | |||
margin-right: auto; | |||
} |
@@ -48,7 +48,7 @@ interface State { | |||
newTokenType?: TokenType; | |||
tokens: UserToken[]; | |||
projects: BasicSelectOption[]; | |||
selectedProjectkey?: string; | |||
selectedProject: { key: string; name: string }; | |||
} | |||
export class TokensForm extends React.PureComponent<Props, State> { | |||
@@ -58,7 +58,7 @@ export class TokensForm extends React.PureComponent<Props, State> { | |||
loading: true, | |||
newTokenName: '', | |||
newTokenType: this.props.displayTokenTypeInput ? undefined : TokenType.User, | |||
selectedProjectkey: '', | |||
selectedProject: { key: '', name: '' }, | |||
tokens: [], | |||
projects: [] | |||
}; | |||
@@ -105,7 +105,7 @@ export class TokensForm extends React.PureComponent<Props, State> { | |||
handleGenerateToken = async (event: React.SyntheticEvent<HTMLFormElement>) => { | |||
event.preventDefault(); | |||
const { login } = this.props; | |||
const { newTokenName, newTokenType, selectedProjectkey } = this.state; | |||
const { newTokenName, newTokenType = TokenType.User, selectedProject } = this.state; | |||
this.setState({ generating: true }); | |||
try { | |||
@@ -113,17 +113,27 @@ export class TokensForm extends React.PureComponent<Props, State> { | |||
name: newTokenName, | |||
login, | |||
type: newTokenType, | |||
...(newTokenType === TokenType.Project && { projectKey: selectedProjectkey }) | |||
...(newTokenType === TokenType.Project && { projectKey: selectedProject.key }) | |||
}); | |||
if (this.mounted) { | |||
this.setState(state => { | |||
const tokens = [...state.tokens, { name: newToken.name, createdAt: newToken.createdAt }]; | |||
const tokens = [ | |||
...state.tokens, | |||
{ | |||
name: newToken.name, | |||
createdAt: newToken.createdAt, | |||
type: newTokenType, | |||
...(newTokenType === TokenType.Project && { | |||
project: { key: selectedProject.key, name: selectedProject.name } | |||
}) | |||
} | |||
]; | |||
return { | |||
generating: false, | |||
newToken, | |||
newTokenName: '', | |||
selectedProjectkey: '', | |||
selectedProject: { key: '', name: '' }, | |||
newTokenType: undefined, | |||
tokens | |||
}; | |||
@@ -147,7 +157,7 @@ export class TokensForm extends React.PureComponent<Props, State> { | |||
isSubmitButtonDisabled = () => { | |||
const { displayTokenTypeInput } = this.props; | |||
const { generating, newTokenName, newTokenType, selectedProjectkey } = this.state; | |||
const { generating, newTokenName, newTokenType, selectedProject } = this.state; | |||
if (!displayTokenTypeInput) { | |||
return generating || newTokenName.length <= 0; | |||
@@ -157,7 +167,7 @@ export class TokensForm extends React.PureComponent<Props, State> { | |||
return true; | |||
} | |||
if (newTokenType === TokenType.Project) { | |||
return !selectedProjectkey; | |||
return !selectedProject.key; | |||
} | |||
return !newTokenType; | |||
@@ -174,12 +184,12 @@ export class TokensForm extends React.PureComponent<Props, State> { | |||
this.setState({ newTokenType: value }); | |||
}; | |||
handleProjectChange = ({ value }: { value: string }) => { | |||
this.setState({ selectedProjectkey: value }); | |||
handleProjectChange = ({ value, label }: { value: string; label: string }) => { | |||
this.setState({ selectedProject: { key: value, name: label } }); | |||
}; | |||
renderForm() { | |||
const { newTokenName, newTokenType, projects, selectedProjectkey } = this.state; | |||
const { newTokenName, newTokenType, projects, selectedProject } = this.state; | |||
const { displayTokenTypeInput, currentUser } = this.props; | |||
const tokenTypeOptions = [ | |||
@@ -207,7 +217,7 @@ export class TokensForm extends React.PureComponent<Props, State> { | |||
{displayTokenTypeInput && ( | |||
<> | |||
<Select | |||
className="input-medium spacer-right it__token-type" | |||
className="input-large spacer-right it__token-type" | |||
isSearchable={false} | |||
onChange={this.handleNewTokenTypeChange} | |||
options={tokenTypeOptions} | |||
@@ -216,11 +226,11 @@ export class TokensForm extends React.PureComponent<Props, State> { | |||
/> | |||
{newTokenType === TokenType.Project && ( | |||
<Select | |||
className="input-medium spacer-right it__project" | |||
className="input-large spacer-right it__project" | |||
onChange={this.handleProjectChange} | |||
options={projects} | |||
placeholder={translate('users.select_token_project')} | |||
value={projects.find(project => project.value === selectedProjectkey)} | |||
value={projects.find(project => project.value === selectedProject.key)} | |||
/> | |||
)} | |||
</> | |||
@@ -271,10 +281,12 @@ export class TokensForm extends React.PureComponent<Props, State> { | |||
{this.renderForm()} | |||
{newToken && <TokensFormNewToken token={newToken} />} | |||
<table className="data zebra big-spacer-top"> | |||
<table className="data zebra big-spacer-top fixed"> | |||
<thead> | |||
<tr> | |||
<th>{translate('name')}</th> | |||
<th>{translate('my_account.token_type')}</th> | |||
<th>{translate('my_account.project_name')}</th> | |||
<th>{translate('my_account.tokens_last_usage')}</th> | |||
<th className="text-right">{translate('created')}</th> | |||
<th /> |
@@ -22,12 +22,10 @@ import { FormattedMessage } from 'react-intl'; | |||
import { revokeToken } from '../../../api/user-tokens'; | |||
import { Button } from '../../../components/controls/buttons'; | |||
import ConfirmButton from '../../../components/controls/ConfirmButton'; | |||
import Tooltip from '../../../components/controls/Tooltip'; | |||
import DateFormatter from '../../../components/intl/DateFormatter'; | |||
import DateFromNow from '../../../components/intl/DateFromNow'; | |||
import DeferredSpinner from '../../../components/ui/DeferredSpinner'; | |||
import { translate } from '../../../helpers/l10n'; | |||
import { limitComponentName } from '../../../helpers/path'; | |||
import { UserToken } from '../../../types/token'; | |||
export type TokenDeleteConfirmation = 'inline' | 'modal'; | |||
@@ -44,8 +42,6 @@ interface State { | |||
showConfirmation: boolean; | |||
} | |||
const MAX_TOKEN_NAME_FIELD = 20; | |||
export default class TokensFormItem extends React.PureComponent<Props, State> { | |||
mounted = false; | |||
state: State = { loading: false, showConfirmation: false }; | |||
@@ -87,12 +83,16 @@ export default class TokensFormItem extends React.PureComponent<Props, State> { | |||
const { loading, showConfirmation } = this.state; | |||
return ( | |||
<tr> | |||
<td> | |||
<Tooltip overlay={token.name}> | |||
<span>{limitComponentName(token.name, MAX_TOKEN_NAME_FIELD)}</span> | |||
</Tooltip> | |||
<td title={token.name} className="hide-overflow nowrap"> | |||
{token.name} | |||
</td> | |||
<td title={translate('users.tokens', token.type)} className="hide-overflow thin"> | |||
{translate('users.tokens', token.type, 'short')} | |||
</td> | |||
<td title={token.project?.name} className="hide-overflow"> | |||
{token.project?.name} | |||
</td> | |||
<td className="nowrap"> | |||
<td className="thin nowrap"> | |||
<DateFromNow date={token.lastConnectionDate} hourPrecision={true} /> | |||
</td> | |||
<td className="thin nowrap text-right"> |
@@ -33,7 +33,7 @@ interface Props { | |||
export default function TokensFormModal(props: Props) { | |||
return ( | |||
<Modal contentLabel={translate('users.tokens')} onRequestClose={props.onClose}> | |||
<Modal size="medium" contentLabel={translate('users.tokens')} onRequestClose={props.onClose}> | |||
<header className="modal-head"> | |||
<h2> | |||
<FormattedMessage |
@@ -20,6 +20,7 @@ | |||
import { shallow } from 'enzyme'; | |||
import * as React from 'react'; | |||
import { generateToken, getTokens } from '../../../../api/user-tokens'; | |||
import { mockUserToken } from '../../../../helpers/mocks/token'; | |||
import { mockCurrentUser } from '../../../../helpers/testMocks'; | |||
import { change, submit, waitAndUpdate } from '../../../../helpers/testUtils'; | |||
import { TokenType } from '../../../../types/token'; | |||
@@ -75,7 +76,9 @@ it('should revoke tokens', async () => { | |||
await waitAndUpdate(wrapper); | |||
expect(wrapper.find('TokensFormItem')).toHaveLength(2); | |||
wrapper.instance().handleRevokeToken({ createdAt: '2019-01-15T15:06:33+0100', name: 'foo' }); | |||
wrapper | |||
.instance() | |||
.handleRevokeToken(mockUserToken({ createdAt: '2019-01-15T15:06:33+0100', name: 'foo' })); | |||
expect(updateTokensCount).toHaveBeenCalledWith('luke', 1); | |||
expect(wrapper.find('TokensFormItem')).toHaveLength(1); | |||
}); |
@@ -20,8 +20,8 @@ | |||
import { shallow } from 'enzyme'; | |||
import * as React from 'react'; | |||
import { revokeToken } from '../../../../api/user-tokens'; | |||
import { mockUserToken } from '../../../../helpers/mocks/token'; | |||
import { click, waitAndUpdate } from '../../../../helpers/testUtils'; | |||
import { UserToken } from '../../../../types/token'; | |||
import TokensFormItem from '../TokensFormItem'; | |||
jest.mock('../../../../components/intl/DateFormatter'); | |||
@@ -32,11 +32,11 @@ jest.mock('../../../../api/user-tokens', () => ({ | |||
revokeToken: jest.fn().mockResolvedValue(undefined) | |||
})); | |||
const userToken: UserToken = { | |||
const userToken = mockUserToken({ | |||
name: 'foo', | |||
createdAt: '2019-01-15T15:06:33+0100', | |||
lastConnectionDate: '2019-01-18T15:06:33+0100' | |||
}; | |||
}); | |||
beforeEach(() => { | |||
(revokeToken as jest.Mock).mockClear(); |
@@ -29,13 +29,19 @@ exports[`should render correctly 1`] = ` | |||
</SubmitButton> | |||
</form> | |||
<table | |||
className="data zebra big-spacer-top" | |||
className="data zebra big-spacer-top fixed" | |||
> | |||
<thead> | |||
<tr> | |||
<th> | |||
name | |||
</th> | |||
<th> | |||
my_account.token_type | |||
</th> | |||
<th> | |||
my_account.project_name | |||
</th> | |||
<th> | |||
my_account.tokens_last_usage | |||
</th> | |||
@@ -103,13 +109,19 @@ exports[`should render correctly 2`] = ` | |||
</SubmitButton> | |||
</form> | |||
<table | |||
className="data zebra big-spacer-top" | |||
className="data zebra big-spacer-top fixed" | |||
> | |||
<thead> | |||
<tr> | |||
<th> | |||
name | |||
</th> | |||
<th> | |||
my_account.token_type | |||
</th> | |||
<th> | |||
my_account.project_name | |||
</th> | |||
<th> | |||
my_account.tokens_last_usage | |||
</th> |
@@ -2,17 +2,23 @@ | |||
exports[`should render correctly 1`] = ` | |||
<tr> | |||
<td> | |||
<Tooltip | |||
overlay="foo" | |||
> | |||
<span> | |||
foo | |||
</span> | |||
</Tooltip> | |||
<td | |||
className="hide-overflow nowrap" | |||
title="foo" | |||
> | |||
foo | |||
</td> | |||
<td | |||
className="nowrap" | |||
className="hide-overflow thin" | |||
title="users.tokens.USER_TOKEN" | |||
> | |||
users.tokens.USER_TOKEN.short | |||
</td> | |||
<td | |||
className="hide-overflow" | |||
/> | |||
<td | |||
className="thin nowrap" | |||
> | |||
<DateFromNow | |||
date="2019-01-18T15:06:33+0100" | |||
@@ -50,17 +56,23 @@ exports[`should render correctly 1`] = ` | |||
exports[`should render correctly 2`] = ` | |||
<tr> | |||
<td> | |||
<Tooltip | |||
overlay="foo" | |||
> | |||
<span> | |||
foo | |||
</span> | |||
</Tooltip> | |||
<td | |||
className="hide-overflow nowrap" | |||
title="foo" | |||
> | |||
foo | |||
</td> | |||
<td | |||
className="nowrap" | |||
className="hide-overflow thin" | |||
title="users.tokens.USER_TOKEN" | |||
> | |||
users.tokens.USER_TOKEN.short | |||
</td> | |||
<td | |||
className="hide-overflow" | |||
/> | |||
<td | |||
className="thin nowrap" | |||
> | |||
<DateFromNow | |||
date="2019-01-18T15:06:33+0100" |
@@ -4,6 +4,7 @@ exports[`should render correctly 1`] = ` | |||
<Modal | |||
contentLabel="users.tokens" | |||
onRequestClose={[MockFunction]} | |||
size="medium" | |||
> | |||
<header | |||
className="modal-head" |
@@ -22,6 +22,7 @@ import { | |||
mockProjectBitbucketCloudBindingResponse, | |||
mockProjectGithubBindingResponse | |||
} from '../../../helpers/mocks/alm-settings'; | |||
import { mockUserToken } from '../../../helpers/mocks/token'; | |||
import { UserToken } from '../../../types/token'; | |||
import { buildBitbucketCloudLink, buildGithubLink, getUniqueTokenName } from '../utils'; | |||
@@ -35,15 +36,15 @@ describe('getUniqueTokenName', () => { | |||
}); | |||
it('should generate a token with the given name', () => { | |||
const userTokens = [{ name: initialTokenName, createdAt: '2019-06-14T09:45:52+0200' }]; | |||
expect(getUniqueTokenName(userTokens, 'Analyze "project"')).toBe('Analyze "project"'); | |||
expect( | |||
getUniqueTokenName([mockUserToken({ name: initialTokenName })], 'Analyze "project"') | |||
).toBe('Analyze "project"'); | |||
}); | |||
it('should generate a unique token when the name already exists', () => { | |||
const userTokens = [ | |||
{ name: initialTokenName, createdAt: '2019-06-15T09:45:52+0200' }, | |||
{ name: `${initialTokenName} 1`, createdAt: '2019-06-15T09:45:53+0200' } | |||
mockUserToken({ name: initialTokenName }), | |||
mockUserToken({ name: `${initialTokenName} 1` }) | |||
]; | |||
expect(getUniqueTokenName(userTokens, initialTokenName)).toBe('Analyze "lightsaber" 2'); |
@@ -0,0 +1,30 @@ | |||
/* | |||
* SonarQube | |||
* Copyright (C) 2009-2022 SonarSource SA | |||
* mailto:info AT sonarsource DOT com | |||
* | |||
* This program is free software; you can redistribute it and/or | |||
* modify it under the terms of the GNU Lesser General Public | |||
* License as published by the Free Software Foundation; either | |||
* version 3 of the License, or (at your option) any later version. | |||
* | |||
* This program is distributed in the hope that it will be useful, | |||
* but WITHOUT ANY WARRANTY; without even the implied warranty of | |||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU | |||
* Lesser General Public License for more details. | |||
* | |||
* You should have received a copy of the GNU Lesser General Public License | |||
* along with this program; if not, write to the Free Software Foundation, | |||
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. | |||
*/ | |||
import { TokenType, UserToken } from '../../types/token'; | |||
export function mockUserToken(overrides: Partial<UserToken> = {}): UserToken { | |||
return { | |||
name: 'Token name', | |||
createdAt: '2019-06-14T09:45:52+0200', | |||
type: TokenType.User, | |||
...overrides | |||
}; | |||
} |
@@ -28,7 +28,7 @@ export interface UserToken { | |||
name: string; | |||
createdAt: string; | |||
lastConnectionDate?: string; | |||
type?: TokenType; | |||
type: TokenType; | |||
project?: { name: string; key: string }; | |||
} | |||
@@ -1996,6 +1996,8 @@ my_account.no_project_notifications=You have not set project notifications yet. | |||
my_account.profile=Profile | |||
my_account.security=Security | |||
my_account.tokens_description=If you want to enforce security by not providing credentials of a real {instance} user to run your code scan or to invoke web services, you can provide a User Token as a replacement of the user login. This will increase the security of your installation by not letting your analysis user's password going through your network. | |||
my_account.token_type=Type | |||
my_account.project_name=Project | |||
my_account.tokens_last_usage=Last use | |||
my_account.projects=Projects | |||
my_account.projects.description=Those projects are the ones you are administering. | |||
@@ -4078,8 +4080,11 @@ users.tokens.revoke_token=Revoke token | |||
users.no_tokens=No tokens | |||
users.generate=Generate | |||
users.tokens.PROJECT_ANALYSIS_TOKEN=Project Analysis Token | |||
users.tokens.PROJECT_ANALYSIS_TOKEN.short=Project | |||
users.tokens.GLOBAL_ANALYSIS_TOKEN=Global Analysis Token | |||
users.tokens.GLOBAL_ANALYSIS_TOKEN.short=Global | |||
users.tokens.USER_TOKEN=User Token | |||
users.tokens.USER_TOKEN.short=User | |||
users.generate_tokens=Generate Tokens | |||
users.enter_token_name=Enter Token Name | |||
users.select_token_type=Select Token Type |