diff options
author | Viktor Vorona <viktor.vorona@sonarsource.com> | 2023-08-18 15:44:48 +0200 |
---|---|---|
committer | sonartech <sonartech@sonarsource.com> | 2023-08-22 20:03:05 +0000 |
commit | 2d65c6839f5baf2e7c946d06683f00877546ad50 (patch) | |
tree | 3d84260ef15b71265ece62f1c2b9aa951eb310e7 /server/sonar-web | |
parent | a8087e5f41a3797a236bbb49da20650e95c8cae0 (diff) | |
download | sonarqube-2d65c6839f5baf2e7c946d06683f00877546ad50.tar.gz sonarqube-2d65c6839f5baf2e7c946d06683f00877546ad50.zip |
SONAR-20184 Remove groupsCount and tokensCount from user
Diffstat (limited to 'server/sonar-web')
12 files changed, 377 insertions, 474 deletions
diff --git a/server/sonar-web/src/main/js/api/mocks/UserTokensMock.ts b/server/sonar-web/src/main/js/api/mocks/UserTokensMock.ts index f0f9f523a9e..5acdf231a27 100644 --- a/server/sonar-web/src/main/js/api/mocks/UserTokensMock.ts +++ b/server/sonar-web/src/main/js/api/mocks/UserTokensMock.ts @@ -41,9 +41,9 @@ export default class UserTokensMock { constructor() { this.tokens = cloneDeep(defaultTokens); - (getTokens as jest.Mock).mockImplementation(this.handleGetTokens); - (generateToken as jest.Mock).mockImplementation(this.handleGenerateToken); - (revokeToken as jest.Mock).mockImplementation(this.handleRevokeToken); + jest.mocked(getTokens).mockImplementation(this.handleGetTokens); + jest.mocked(generateToken).mockImplementation(this.handleGenerateToken); + jest.mocked(revokeToken).mockImplementation(this.handleRevokeToken); } handleGetTokens = () => { @@ -58,7 +58,7 @@ export default class UserTokensMock { expirationDate, }: { name: string; - login?: string; + login: string; type: TokenType; projectKey: string; expirationDate?: string; diff --git a/server/sonar-web/src/main/js/api/mocks/UsersServiceMock.ts b/server/sonar-web/src/main/js/api/mocks/UsersServiceMock.ts index 13b5e1e0d0c..ed92fd94dff 100644 --- a/server/sonar-web/src/main/js/api/mocks/UsersServiceMock.ts +++ b/server/sonar-web/src/main/js/api/mocks/UsersServiceMock.ts @@ -55,7 +55,6 @@ const DEFAULT_USERS = [ sonarQubeLastConnectionDate: '2023-06-27T17:08:59+0200', sonarLintLastConnectionDate: '2023-05-27T17:08:59+0200', email: 'alice.merveille@wonderland.com', - groupsCount: 2, }), mockRestUser({ managed: false, @@ -109,6 +108,13 @@ const DEFAULT_GROUPS: UserGroup[] = [ id: 1003, name: 'test3', description: 'test3', + selected: true, + default: false, + }, + { + id: 1004, + name: 'test4', + description: 'test4', selected: false, default: false, }, @@ -314,18 +320,25 @@ export default class UsersServiceMock { }; handleGetUserGroups: typeof getUserGroups = (data) => { + if (data.login !== 'alice.merveille') { + return this.reply({ + paging: { pageIndex: 1, pageSize: 10, total: 0 }, + groups: [], + }); + } const filteredGroups = this.groups .filter((g) => g.name.includes(data.q ?? '')) .filter((g) => { switch (data.selected) { - case 'selected': - return g.selected; + case 'all': + return true; case 'deselected': return !g.selected; default: - return true; + return g.selected; } }); + return this.reply({ paging: { pageIndex: 1, pageSize: 10, total: filteredGroups.length }, groups: filteredGroups, @@ -334,7 +347,6 @@ export default class UsersServiceMock { handleAddUserToGroup: typeof addUserToGroup = ({ name }) => { this.groups = this.groups.map((g) => (g.name === name ? { ...g, selected: true } : g)); - this.users.find((u) => u.login === 'alice.merveille')!.groupsCount++; return this.reply({}); }; @@ -350,7 +362,6 @@ export default class UsersServiceMock { } return g; }); - this.users.find((u) => u.login === 'alice.merveille')!.groupsCount--; return isDefault ? Promise.reject({ errors: [{ msg: 'Cannot remove Default group' }], diff --git a/server/sonar-web/src/main/js/apps/account/__tests__/Account-it.tsx b/server/sonar-web/src/main/js/apps/account/__tests__/Account-it.tsx index e050c593a66..32567821c96 100644 --- a/server/sonar-web/src/main/js/apps/account/__tests__/Account-it.tsx +++ b/server/sonar-web/src/main/js/apps/account/__tests__/Account-it.tsx @@ -17,7 +17,7 @@ * along with this program; if not, write to the Free Software Foundation, * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ -import { screen, within } from '@testing-library/react'; +import { screen, waitFor, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { UserEvent } from '@testing-library/user-event/dist/types/setup/setup'; import selectEvent from 'react-select-event'; @@ -274,7 +274,7 @@ describe('security page', () => { ); expect(await screen.findByText('users.tokens')).toBeInTheDocument(); - expect(screen.getAllByRole('row')).toHaveLength(3); // 2 tokens + header + await waitFor(() => expect(screen.getAllByRole('row')).toHaveLength(3)); // 2 tokens + header // Add the token const newTokenName = 'importantToken'; @@ -356,7 +356,7 @@ describe('security page', () => { await user.click(screen.getByRole('button', { name: 'yes' })); - expect(screen.getAllByRole('row')).toHaveLength(3); // 2 tokens + header + await waitFor(() => expect(screen.getAllByRole('row')).toHaveLength(3)); // 2 tokens + header } ); @@ -377,7 +377,7 @@ describe('security page', () => { expect(await screen.findByText('users.tokens')).toBeInTheDocument(); // expired token is flagged as such - const expiredTokenRow = screen.getByRole('row', { name: /expired token/ }); + const expiredTokenRow = await screen.findByRole('row', { name: /expired token/ }); expect(within(expiredTokenRow).getByText('my_account.tokens.expired')).toBeInTheDocument(); // unexpired token is not flagged diff --git a/server/sonar-web/src/main/js/apps/users/__tests__/UsersApp-it.tsx b/server/sonar-web/src/main/js/apps/users/__tests__/UsersApp-it.tsx index d7a0eda66de..c8d2d0957e3 100644 --- a/server/sonar-web/src/main/js/apps/users/__tests__/UsersApp-it.tsx +++ b/server/sonar-web/src/main/js/apps/users/__tests__/UsersApp-it.tsx @@ -283,15 +283,15 @@ describe('in non managed mode', () => { it('should be able to edit the groups of a user', async () => { const user = userEvent.setup(); renderUsersApp(); - expect(await within(await ui.aliceRow.find()).findByText('2')).toBeInTheDocument(); + expect(await within(await ui.aliceRow.find()).findByText('3')).toBeInTheDocument(); await act(async () => user.click(await ui.aliceUpdateGroupButton.find())); expect(await ui.dialogGroups.find()).toBeInTheDocument(); - expect(ui.getGroups()).toHaveLength(2); + expect(ui.getGroups()).toHaveLength(3); await act(async () => user.click(await ui.allFilter.find())); - expect(ui.getGroups()).toHaveLength(3); + expect(ui.getGroups()).toHaveLength(4); await act(() => user.click(ui.unselectedFilter.get())); expect(ui.reloadButton.query()).not.toBeInTheDocument(); @@ -299,11 +299,11 @@ describe('in non managed mode', () => { expect(await ui.reloadButton.find()).toBeInTheDocument(); await act(() => user.click(ui.selectedFilter.get())); - expect(ui.getGroups()).toHaveLength(3); + expect(ui.getGroups()).toHaveLength(4); await act(() => user.click(ui.doneButton.get())); expect(ui.dialogGroups.query()).not.toBeInTheDocument(); - expect(await within(await ui.aliceRow.find()).findByText('3')).toBeInTheDocument(); + expect(await within(await ui.aliceRow.find()).findByText('4')).toBeInTheDocument(); await act(async () => user.click(await ui.aliceUpdateGroupButton.find())); @@ -312,15 +312,15 @@ describe('in non managed mode', () => { await act(() => user.click(ui.getGroups()[1])); expect(await ui.reloadButton.find()).toBeInTheDocument(); await act(() => user.click(ui.reloadButton.get())); - expect(ui.getGroups()).toHaveLength(2); + expect(ui.getGroups()).toHaveLength(3); - await act(() => user.type(within(ui.dialogGroups.get()).getByRole('searchbox'), '3')); + await act(() => user.type(within(ui.dialogGroups.get()).getByRole('searchbox'), '4')); expect(ui.getGroups()).toHaveLength(1); await act(() => user.click(ui.doneButton.get())); expect(ui.dialogGroups.query()).not.toBeInTheDocument(); - expect(await within(await ui.aliceRow.find()).findByText('2')).toBeInTheDocument(); + expect(await within(await ui.aliceRow.find()).findByText('3')).toBeInTheDocument(); }); it('should update user', async () => { @@ -388,7 +388,7 @@ describe('in non managed mode', () => { expect( screen.queryByText(`user.${ChangePasswordResults.OldPasswordIncorrect}`) ).not.toBeInTheDocument(); - await user.click(ui.changeButton.get()); + await act(() => user.click(ui.changeButton.get())); expect( await within(ui.dialogPasswords.get()).findByText( `user.${ChangePasswordResults.OldPasswordIncorrect}` @@ -405,7 +405,7 @@ describe('in non managed mode', () => { expect( screen.queryByText(`user.${ChangePasswordResults.NewPasswordSameAsOld}`) ).not.toBeInTheDocument(); - await user.click(ui.changeButton.get()); + await act(() => user.click(ui.changeButton.get())); expect( await screen.findByText(`user.${ChangePasswordResults.NewPasswordSameAsOld}`) ).toBeInTheDocument(); @@ -415,7 +415,7 @@ describe('in non managed mode', () => { await user.type(ui.newPassword.get(), 'test2'); await user.type(ui.confirmPassword.get(), 'test2'); - await user.click(ui.changeButton.get()); + await act(() => user.click(ui.changeButton.get())); expect(ui.dialogPasswords.query()).not.toBeInTheDocument(); }); diff --git a/server/sonar-web/src/main/js/apps/users/components/GroupsForm.tsx b/server/sonar-web/src/main/js/apps/users/components/GroupsForm.tsx index e27d00ec0d3..aa47555884c 100644 --- a/server/sonar-web/src/main/js/apps/users/components/GroupsForm.tsx +++ b/server/sonar-web/src/main/js/apps/users/components/GroupsForm.tsx @@ -19,7 +19,6 @@ */ import { find, without } from 'lodash'; import * as React from 'react'; -import { addUserToGroup, removeUserFromGroup } from '../../../api/user_groups'; import { UserGroup, getUserGroups } from '../../../api/users'; import Modal from '../../../components/controls/Modal'; import SelectList, { @@ -28,7 +27,7 @@ import SelectList, { } from '../../../components/controls/SelectList'; import { ResetButtonLink } from '../../../components/controls/buttons'; import { translate } from '../../../helpers/l10n'; -import { useInvalidateUsersList } from '../../../queries/users'; +import { useAddUserToGroupMutation, useRemoveUserToGroupMutation } from '../../../queries/users'; import { RestUserDetailed } from '../../../types/users'; interface Props { @@ -45,8 +44,8 @@ export default function GroupsForm(props: Props) { const [groups, setGroups] = React.useState<UserGroup[]>([]); const [groupsTotalCount, setGroupsTotalCount] = React.useState<number | undefined>(undefined); const [selectedGroups, setSelectedGroups] = React.useState<string[]>([]); - - const invalidateUserList = useInvalidateUsersList(); + const { mutateAsync: addUserToGroup } = useAddUserToGroupMutation(); + const { mutateAsync: removeUserFromGroup } = useRemoveUserToGroupMutation(); const fetchUsers = (searchParams: SelectListSearchParams) => getUserGroups({ @@ -86,11 +85,6 @@ export default function GroupsForm(props: Props) { setSelectedGroups(without(selectedGroups, name)); }); - const handleClose = () => { - invalidateUserList(); - props.onClose(); - }; - const renderElement = (name: string): React.ReactNode => { const group = find(groups, { name }); return ( @@ -111,7 +105,7 @@ export default function GroupsForm(props: Props) { const header = translate('users.update_groups'); return ( - <Modal contentLabel={header} onRequestClose={handleClose}> + <Modal contentLabel={header} onRequestClose={props.onClose}> <div className="modal-head"> <h2>{header}</h2> </div> @@ -133,7 +127,7 @@ export default function GroupsForm(props: Props) { </div> <footer className="modal-foot"> - <ResetButtonLink onClick={handleClose}>{translate('done')}</ResetButtonLink> + <ResetButtonLink onClick={props.onClose}>{translate('done')}</ResetButtonLink> </footer> </Modal> ); diff --git a/server/sonar-web/src/main/js/apps/users/components/TokensForm.tsx b/server/sonar-web/src/main/js/apps/users/components/TokensForm.tsx index 079006f6469..626ecb118e8 100644 --- a/server/sonar-web/src/main/js/apps/users/components/TokensForm.tsx +++ b/server/sonar-web/src/main/js/apps/users/components/TokensForm.tsx @@ -20,7 +20,6 @@ import { isEmpty } from 'lodash'; import * as React from 'react'; import { getScannableProjects } from '../../../api/components'; -import { generateToken, getTokens } from '../../../api/user-tokens'; import withCurrentUserContext from '../../../app/components/current-user/withCurrentUserContext'; import Select, { LabelValueSelectOption } from '../../../components/controls/Select'; import { SubmitButton } from '../../../components/controls/buttons'; @@ -32,8 +31,9 @@ import { getAvailableExpirationOptions, } from '../../../helpers/tokens'; import { hasGlobalPermission } from '../../../helpers/users'; +import { useGenerateTokenMutation, useUserTokensQuery } from '../../../queries/users'; import { Permissions } from '../../../types/permissions'; -import { TokenExpiration, TokenType, UserToken } from '../../../types/token'; +import { TokenExpiration, TokenType } from '../../../types/token'; import { CurrentUser } from '../../../types/users'; import TokensFormItem, { TokenDeleteConfirmation } from './TokensFormItem'; import TokensFormNewToken from './TokensFormNewToken'; @@ -41,199 +41,99 @@ import TokensFormNewToken from './TokensFormNewToken'; interface Props { deleteConfirmation: TokenDeleteConfirmation; login: string; - updateTokensCount?: (login: string, tokensCount: number) => void; displayTokenTypeInput: boolean; currentUser: CurrentUser; } -interface State { - generating: boolean; - loading: boolean; - newToken?: { name: string; token: string }; - newTokenName: string; - newTokenType?: TokenType; - tokens: UserToken[]; - projects: LabelValueSelectOption[]; - selectedProject?: LabelValueSelectOption; - newTokenExpiration: TokenExpiration; - tokenExpirationOptions: { value: TokenExpiration; label: string }[]; - tokenTypeOptions: Array<{ label: string; value: TokenType }>; -} - -export class TokensForm extends React.PureComponent<Props, State> { - mounted = false; - state: State = { - generating: false, - loading: true, - newTokenName: '', - newTokenType: this.props.displayTokenTypeInput ? undefined : TokenType.User, - tokens: [], - projects: [], - newTokenExpiration: TokenExpiration.OneMonth, - tokenExpirationOptions: EXPIRATION_OPTIONS, - tokenTypeOptions: [], - }; - - componentDidMount() { - this.mounted = true; - this.loadData(); - } - - componentWillUnmount() { - this.mounted = false; - } - - loadData = async () => { - this.fetchTokens(); - this.fetchTokenSettings(); - - if (this.props.displayTokenTypeInput) { - const projects = await this.fetchProjects(); - this.constructTokenTypeOptions(projects); - } - }; - - fetchTokens = () => { - this.setState({ loading: true }); - getTokens(this.props.login).then( - (tokens) => { - if (this.mounted) { - this.setState({ loading: false, tokens }); - } - }, - () => { - if (this.mounted) { - this.setState({ loading: false }); - } - } - ); - }; - - fetchTokenSettings = async () => { - const tokenExpirationOptions = await getAvailableExpirationOptions(); - if (this.mounted) { - this.setState({ tokenExpirationOptions }); - } - }; - - fetchProjects = async () => { - const { projects: projectArray } = await getScannableProjects(); - const projects = projectArray.map((project) => ({ label: project.name, value: project.key })); - - this.setState({ - projects, - selectedProject: projects.length === 1 ? projects[0] : undefined, - }); - - return projects; - }; - - constructTokenTypeOptions = (projects: LabelValueSelectOption[]) => { - const { currentUser } = this.props; +export function TokensForm(props: Props) { + const { currentUser, deleteConfirmation, displayTokenTypeInput, login } = props; + const { data: tokens, isLoading: loading } = useUserTokensQuery(login); + const [newToken, setNewToken] = React.useState<{ name: string; token: string }>(); + const [newTokenName, setNewTokenName] = React.useState(''); + const [newTokenType, setNewTokenType] = React.useState<TokenType>(); + const [projects, setProjects] = React.useState<LabelValueSelectOption[]>([]); + const [selectedProject, setSelectedProject] = React.useState<LabelValueSelectOption>(); + const [newTokenExpiration, setNewTokenExpiration] = React.useState<TokenExpiration>( + TokenExpiration.OneMonth + ); + const [tokenExpirationOptions, setTokenExpirationOptions] = + React.useState<{ value: TokenExpiration; label: string }[]>(EXPIRATION_OPTIONS); + + const { mutateAsync: generate, isLoading: generating } = useGenerateTokenMutation(); + + const tokenTypeOptions = React.useMemo(() => { + const value = [{ label: translate('users.tokens', TokenType.User), value: TokenType.User }]; - const tokenTypeOptions = [ - { label: translate('users.tokens', TokenType.User), value: TokenType.User }, - ]; if (hasGlobalPermission(currentUser, Permissions.Scan)) { - tokenTypeOptions.unshift({ + value.unshift({ label: translate('users.tokens', TokenType.Global), value: TokenType.Global, }); } if (!isEmpty(projects)) { - tokenTypeOptions.unshift({ + value.unshift({ label: translate('users.tokens', TokenType.Project), value: TokenType.Project, }); } + return value; + }, [projects, currentUser]); + + React.useEffect(() => { if (tokenTypeOptions.length === 1) { - this.setState({ - newTokenType: tokenTypeOptions[0].value, - tokenTypeOptions, - }); - } else { - this.setState({ tokenTypeOptions }); + setNewTokenType(tokenTypeOptions[0].value); } - }; - - updateTokensCount = () => { - if (this.props.updateTokensCount) { - this.props.updateTokensCount(this.props.login, this.state.tokens.length); + }, [tokenTypeOptions]); + + React.useEffect(() => { + getAvailableExpirationOptions() + .then((options) => { + setTokenExpirationOptions(options); + }) + .catch(() => {}); + + if (displayTokenTypeInput) { + getScannableProjects() + .then(({ projects: projectArray }) => { + const projects = projectArray.map((project) => ({ + label: project.name, + value: project.key, + })); + setProjects(projects); + setSelectedProject(projects.length === 1 ? projects[0] : undefined); + }) + .catch(() => {}); } - }; + }, [displayTokenTypeInput, currentUser]); - handleGenerateToken = async (event: React.SyntheticEvent<HTMLFormElement>) => { + const handleGenerateToken = (event: React.SyntheticEvent<HTMLFormElement>) => { event.preventDefault(); - const { login } = this.props; - const { - newTokenName, - newTokenType = TokenType.User, - selectedProject, - tokenTypeOptions, - newTokenExpiration, - } = this.state; - this.setState({ generating: true }); - try { - const newToken = await generateToken({ - name: newTokenName, - login, - type: newTokenType, - ...(newTokenType === TokenType.Project && - selectedProject !== undefined && { projectKey: selectedProject.value }), - ...(newTokenExpiration !== TokenExpiration.NoExpiration && { - expirationDate: computeTokenExpirationDate(newTokenExpiration), + generate({ + name: newTokenName, + login, + type: newTokenType, + ...(newTokenType === TokenType.Project && + selectedProject !== undefined && { + projectKey: selectedProject.value, + projectName: selectedProject.label, }), - }); - - if (this.mounted) { - this.setState((state) => { - const tokens: UserToken[] = [ - ...state.tokens, - { - name: newToken.name, - createdAt: newToken.createdAt, - isExpired: false, - expirationDate: newToken.expirationDate, - type: newTokenType, - ...(newTokenType === TokenType.Project && - selectedProject !== undefined && { - project: { key: selectedProject.value, name: selectedProject.label }, - }), - }, - ]; - return { - generating: false, - newToken, - newTokenName: '', - selectedProject: undefined, - newTokenType: tokenTypeOptions.length === 1 ? tokenTypeOptions[0].value : undefined, - newTokenExpiration: TokenExpiration.OneMonth, - tokens, - }; - }, this.updateTokensCount); - } - } catch (e) { - if (this.mounted) { - this.setState({ generating: false }); - } - } - }; - - handleRevokeToken = (revokedToken: UserToken) => { - this.setState( - (state) => ({ - tokens: state.tokens.filter((token) => token.name !== revokedToken.name), + ...(newTokenExpiration !== TokenExpiration.NoExpiration && { + expirationDate: computeTokenExpirationDate(newTokenExpiration), }), - this.updateTokensCount - ); + }) + .then((newToken) => { + setNewToken(newToken); + setNewTokenName(''); + setSelectedProject(undefined); + setNewTokenType(tokenTypeOptions.length === 1 ? tokenTypeOptions[0].value : undefined); + setNewTokenExpiration(TokenExpiration.OneMonth); + }) + .catch(() => {}); }; - isSubmitButtonDisabled = () => { - const { displayTokenTypeInput } = this.props; - const { generating, newTokenName, newTokenType, selectedProject } = this.state; - + const isSubmitButtonDisabled = () => { if (!displayTokenTypeInput) { return generating || newTokenName.length <= 0; } @@ -248,36 +148,34 @@ export class TokensForm extends React.PureComponent<Props, State> { return !newTokenType; }; - handleNewTokenChange = (evt: React.SyntheticEvent<HTMLInputElement>) => { - this.setState({ newTokenName: evt.currentTarget.value }); + const handleNewTokenChange = (evt: React.SyntheticEvent<HTMLInputElement>) => { + setNewTokenName(evt.currentTarget.value); }; - handleNewTokenTypeChange = ({ value }: { value: TokenType }) => { - this.setState({ newTokenType: value }); + const handleNewTokenTypeChange = ({ value }: { value: TokenType }) => { + setNewTokenType(value); }; - handleProjectChange = (selectedProject: LabelValueSelectOption) => { - this.setState({ selectedProject }); + const handleProjectChange = (selectedProject: LabelValueSelectOption) => { + setSelectedProject(selectedProject); }; - handleNewTokenExpirationChange = ({ value }: { value: TokenExpiration }) => { - this.setState({ newTokenExpiration: value }); + const handleNewTokenExpirationChange = ({ value }: { value: TokenExpiration }) => { + setNewTokenExpiration(value); }; - renderForm() { - const { - newTokenName, - newTokenType, - projects, - selectedProject, - newTokenExpiration, - tokenExpirationOptions, - tokenTypeOptions, - } = this.state; - const { displayTokenTypeInput } = this.props; - - return ( - <form autoComplete="off" className="display-flex-center" onSubmit={this.handleGenerateToken}> + const customSpinner = ( + <tr> + <td> + <i className="spinner" /> + </td> + </tr> + ); + + return ( + <> + <h3 className="spacer-bottom">{translate('users.tokens.generate')}</h3> + <form autoComplete="off" className="display-flex-center" onSubmit={handleGenerateToken}> <div className="display-flex-column input-large spacer-right "> <label htmlFor="token-name" className="text-bold"> {translate('users.tokens.name')} @@ -286,7 +184,7 @@ export class TokensForm extends React.PureComponent<Props, State> { id="token-name" className="spacer-top it__token-name" maxLength={100} - onChange={this.handleNewTokenChange} + onChange={handleNewTokenChange} placeholder={translate('users.tokens.enter_name')} required type="text" @@ -303,7 +201,7 @@ export class TokensForm extends React.PureComponent<Props, State> { inputId="token-select-type" className="spacer-top it__token-type" isSearchable={false} - onChange={this.handleNewTokenTypeChange} + onChange={handleNewTokenTypeChange} options={tokenTypeOptions} placeholder={translate('users.tokens.select_type')} value={ @@ -321,7 +219,7 @@ export class TokensForm extends React.PureComponent<Props, State> { <Select inputId="token-select-project" className="spacer-top it__project" - onChange={this.handleProjectChange} + onChange={handleProjectChange} options={projects} placeholder={translate('users.tokens.select_project')} value={selectedProject} @@ -338,7 +236,7 @@ export class TokensForm extends React.PureComponent<Props, State> { inputId="token-select-expiration" className="spacer-top" isSearchable={false} - onChange={this.handleNewTokenExpirationChange} + onChange={handleNewTokenExpirationChange} options={tokenExpirationOptions} value={tokenExpirationOptions.find((option) => option.value === newTokenExpiration)} /> @@ -346,73 +244,48 @@ export class TokensForm extends React.PureComponent<Props, State> { <SubmitButton className="it__generate-token" style={{ marginTop: 'auto' }} - disabled={this.isSubmitButtonDisabled()} + disabled={isSubmitButtonDisabled()} > {translate('users.generate')} </SubmitButton> </form> - ); - } - - renderItems() { - const { tokens } = this.state; - if (tokens.length <= 0) { - return ( - <tr> - <td className="note" colSpan={7}> - {translate('users.no_tokens')} - </td> - </tr> - ); - } - return tokens.map((token) => ( - <TokensFormItem - deleteConfirmation={this.props.deleteConfirmation} - key={token.name} - login={this.props.login} - onRevokeToken={this.handleRevokeToken} - token={token} - /> - )); - } - - render() { - const { loading, newToken, tokens } = this.state; - const customSpinner = ( - <tr> - <td> - <i className="spinner" /> - </td> - </tr> - ); - - return ( - <> - <h3 className="spacer-bottom">{translate('users.tokens.generate')}</h3> - {this.renderForm()} - {newToken && <TokensFormNewToken token={newToken} />} - - <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 className="text-right">{translate('my_account.tokens.expiration')}</th> - <th className="text-right">{translate('actions')}</th> - </tr> - </thead> - <tbody> - <Spinner customSpinner={customSpinner} loading={loading && tokens.length <= 0}> - {this.renderItems()} - </Spinner> - </tbody> - </table> - </> - ); - } + {newToken && <TokensFormNewToken token={newToken} />} + + <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 className="text-right">{translate('my_account.tokens.expiration')}</th> + <th className="text-right">{translate('actions')}</th> + </tr> + </thead> + <tbody> + <Spinner customSpinner={customSpinner} loading={!!loading}> + {tokens && tokens.length <= 0 ? ( + <tr> + <td className="note" colSpan={7}> + {translate('users.no_tokens')} + </td> + </tr> + ) : ( + tokens?.map((token) => ( + <TokensFormItem + deleteConfirmation={deleteConfirmation} + key={token.name} + login={login} + token={token} + /> + )) + )} + </Spinner> + </tbody> + </table> + </> + ); } export default withCurrentUserContext(TokensForm); diff --git a/server/sonar-web/src/main/js/apps/users/components/TokensFormItem.tsx b/server/sonar-web/src/main/js/apps/users/components/TokensFormItem.tsx index 8e05a173ed8..a45aff21f6c 100644 --- a/server/sonar-web/src/main/js/apps/users/components/TokensFormItem.tsx +++ b/server/sonar-web/src/main/js/apps/users/components/TokensFormItem.tsx @@ -20,7 +20,6 @@ import classNames from 'classnames'; import * as React from 'react'; import { FormattedMessage } from 'react-intl'; -import { revokeToken } from '../../../api/user-tokens'; import ConfirmButton from '../../../components/controls/ConfirmButton'; import { Button } from '../../../components/controls/buttons'; import WarningIcon from '../../../components/icons/WarningIcon'; @@ -28,6 +27,7 @@ import DateFormatter from '../../../components/intl/DateFormatter'; import DateFromNow from '../../../components/intl/DateFromNow'; import Spinner from '../../../components/ui/Spinner'; import { translate, translateWithParameters } from '../../../helpers/l10n'; +import { useRevokeTokenMutation } from '../../../queries/users'; import { UserToken } from '../../../types/token'; export type TokenDeleteConfirmation = 'inline' | 'modal'; @@ -35,136 +35,107 @@ export type TokenDeleteConfirmation = 'inline' | 'modal'; interface Props { deleteConfirmation: TokenDeleteConfirmation; login: string; - onRevokeToken: (token: UserToken) => void; token: UserToken; } -interface State { - loading: boolean; - showConfirmation: boolean; -} - -export default class TokensFormItem extends React.PureComponent<Props, State> { - mounted = false; - state: State = { loading: false, showConfirmation: false }; - - componentDidMount() { - this.mounted = true; - } +export default function TokensFormItem(props: Props) { + const { token, deleteConfirmation, login } = props; + const [showConfirmation, setShowConfirmation] = React.useState(false); + const { mutateAsync, isLoading } = useRevokeTokenMutation(); - componentWillUnmount() { - this.mounted = false; - } + const handleRevoke = () => mutateAsync({ login, name: token.name }); - handleClick = () => { - if (this.state.showConfirmation) { - this.handleRevoke().then(() => { - if (this.mounted) { - this.setState({ showConfirmation: false }); - } - }); + const handleClick = () => { + if (showConfirmation) { + handleRevoke() + .then(() => setShowConfirmation(false)) + .catch(() => setShowConfirmation(false)); } else { - this.setState({ showConfirmation: true }); + setShowConfirmation(true); } }; - handleRevoke = () => { - this.setState({ loading: true }); - return revokeToken({ login: this.props.login, name: this.props.token.name }).then( - () => this.props.onRevokeToken(this.props.token), - () => { - if (this.mounted) { - this.setState({ loading: false }); - } - } - ); - }; - - render() { - const { deleteConfirmation, token } = this.props; - const { loading, showConfirmation } = this.state; - return ( - <tr className={classNames({ 'text-muted-2': token.isExpired })}> - <td title={token.name} className="hide-overflow nowrap"> - {token.name} - {token.isExpired && ( - <div className="spacer-top text-warning"> - <WarningIcon className="little-spacer-right" /> - {translate('my_account.tokens.expired')} - </div> - )} - </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="thin nowrap"> - <DateFromNow date={token.lastConnectionDate} hourPrecision /> - </td> - <td className="thin nowrap text-right"> - <DateFormatter date={token.createdAt} long /> - </td> - <td className={classNames('thin nowrap text-right', { 'text-warning': token.isExpired })}> - {token.expirationDate ? <DateFormatter date={token.expirationDate} long /> : '–'} - </td> - <td className="thin nowrap text-right"> - {token.isExpired && ( - <Button - className="button-red input-small" - disabled={loading} - onClick={this.handleRevoke} - aria-label={translateWithParameters('users.tokens.remove_label', token.name)} - > - <Spinner className="little-spacer-right" loading={loading}> - {translate('remove')} - </Spinner> - </Button> - )} - {!token.isExpired && deleteConfirmation === 'modal' && ( - <ConfirmButton - confirmButtonText={translate('yes')} - isDestructive - modalBody={ - <FormattedMessage - defaultMessage={translate('users.tokens.sure_X')} - id="users.tokens.sure_X" - values={{ token: <strong>{token.name}</strong> }} - /> - } - modalHeader={translateWithParameters('users.tokens.revoke_label', token.name)} - onConfirm={this.handleRevoke} - > - {({ onClick }) => ( - <Button - className="button-red input-small" - disabled={loading} - onClick={onClick} - aria-label={translateWithParameters('users.tokens.revoke_label', token.name)} - > - {translate('users.tokens.revoke')} - </Button> - )} - </ConfirmButton> - )} - {!token.isExpired && deleteConfirmation === 'inline' && ( - <Button - className="button-red input-small" - disabled={loading} - aria-label={ - showConfirmation - ? translate('users.tokens.sure') - : translateWithParameters('users.tokens.revoke_label', token.name) - } - onClick={this.handleClick} - > - <Spinner className="little-spacer-right" loading={loading} /> - {showConfirmation ? translate('users.tokens.sure') : translate('users.tokens.revoke')} - </Button> - )} - </td> - </tr> - ); - } + return ( + <tr className={classNames({ 'text-muted-2': token.isExpired })}> + <td title={token.name} className="hide-overflow nowrap"> + {token.name} + {token.isExpired && ( + <div className="spacer-top text-warning"> + <WarningIcon className="little-spacer-right" /> + {translate('my_account.tokens.expired')} + </div> + )} + </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="thin nowrap"> + <DateFromNow date={token.lastConnectionDate} hourPrecision /> + </td> + <td className="thin nowrap text-right"> + <DateFormatter date={token.createdAt} long /> + </td> + <td className={classNames('thin nowrap text-right', { 'text-warning': token.isExpired })}> + {token.expirationDate ? <DateFormatter date={token.expirationDate} long /> : '–'} + </td> + <td className="thin nowrap text-right"> + {token.isExpired && ( + <Button + className="button-red input-small" + disabled={isLoading} + onClick={handleRevoke} + aria-label={translateWithParameters('users.tokens.remove_label', token.name)} + > + <Spinner className="little-spacer-right" loading={isLoading}> + {translate('remove')} + </Spinner> + </Button> + )} + {!token.isExpired && deleteConfirmation === 'modal' && ( + <ConfirmButton + confirmButtonText={translate('yes')} + isDestructive + modalBody={ + <FormattedMessage + defaultMessage={translate('users.tokens.sure_X')} + id="users.tokens.sure_X" + values={{ token: <strong>{token.name}</strong> }} + /> + } + modalHeader={translateWithParameters('users.tokens.revoke_label', token.name)} + onConfirm={handleRevoke} + > + {({ onClick }) => ( + <Button + className="button-red input-small" + disabled={isLoading} + onClick={onClick} + aria-label={translateWithParameters('users.tokens.revoke_label', token.name)} + > + {translate('users.tokens.revoke')} + </Button> + )} + </ConfirmButton> + )} + {!token.isExpired && deleteConfirmation === 'inline' && ( + <Button + className="button-red input-small" + disabled={isLoading} + aria-label={ + showConfirmation + ? translate('users.tokens.sure') + : translateWithParameters('users.tokens.revoke_label', token.name) + } + onClick={handleClick} + > + <Spinner className="little-spacer-right" loading={isLoading} /> + {showConfirmation ? translate('users.tokens.sure') : translate('users.tokens.revoke')} + </Button> + )} + </td> + </tr> + ); } diff --git a/server/sonar-web/src/main/js/apps/users/components/TokensFormModal.tsx b/server/sonar-web/src/main/js/apps/users/components/TokensFormModal.tsx index 0524be649a7..7969250be5e 100644 --- a/server/sonar-web/src/main/js/apps/users/components/TokensFormModal.tsx +++ b/server/sonar-web/src/main/js/apps/users/components/TokensFormModal.tsx @@ -22,7 +22,6 @@ import { FormattedMessage } from 'react-intl'; import Modal from '../../../components/controls/Modal'; import { ResetButtonLink } from '../../../components/controls/buttons'; import { translate } from '../../../helpers/l10n'; -import { useInvalidateUsersList } from '../../../queries/users'; import { RestUserDetailed } from '../../../types/users'; import TokensForm from './TokensForm'; @@ -32,37 +31,24 @@ interface Props { } export default function TokensFormModal(props: Props) { - const [hasTokenCountChanged, setHasTokenCountChanged] = React.useState(false); - const invalidateUserList = useInvalidateUsersList(); - - const handleClose = () => { - if (hasTokenCountChanged) { - invalidateUserList(); - } - props.onClose(); - }; + const { user } = props; return ( - <Modal size="large" contentLabel={translate('users.tokens')} onRequestClose={handleClose}> + <Modal size="large" contentLabel={translate('users.tokens')} onRequestClose={props.onClose}> <header className="modal-head"> <h2> <FormattedMessage defaultMessage={translate('users.user_X_tokens')} id="users.user_X_tokens" - values={{ user: <em>{props.user.name}</em> }} + values={{ user: <em>{user.name}</em> }} /> </h2> </header> <div className="modal-body modal-container"> - <TokensForm - deleteConfirmation="inline" - login={props.user.login} - updateTokensCount={() => setHasTokenCountChanged(true)} - displayTokenTypeInput={false} - /> + <TokensForm deleteConfirmation="inline" login={user.login} displayTokenTypeInput={false} /> </div> <footer className="modal-foot"> - <ResetButtonLink onClick={handleClose}>{translate('done')}</ResetButtonLink> + <ResetButtonLink onClick={props.onClose}>{translate('done')}</ResetButtonLink> </footer> </Modal> ); diff --git a/server/sonar-web/src/main/js/apps/users/components/UserListItem.tsx b/server/sonar-web/src/main/js/apps/users/components/UserListItem.tsx index e1b74efb7a4..59cddecdc25 100644 --- a/server/sonar-web/src/main/js/apps/users/components/UserListItem.tsx +++ b/server/sonar-web/src/main/js/apps/users/components/UserListItem.tsx @@ -22,7 +22,9 @@ import { ButtonIcon } from '../../../components/controls/buttons'; import BulletListIcon from '../../../components/icons/BulletListIcon'; import DateFromNow from '../../../components/intl/DateFromNow'; import LegacyAvatar from '../../../components/ui/LegacyAvatar'; +import Spinner from '../../../components/ui/Spinner'; import { translate, translateWithParameters } from '../../../helpers/l10n'; +import { useUserGroupsCountQuery, useUserTokensQuery } from '../../../queries/users'; import { IdentityProvider } from '../../../types/types'; import { RestUserDetailed } from '../../../types/users'; import GroupsForm from './GroupsForm'; @@ -42,8 +44,6 @@ export default function UserListItem(props: UserListItemProps) { const { name, login, - groupsCount, - tokensCount, avatar, sonarQubeLastConnectionDate, sonarLintLastConnectionDate, @@ -52,6 +52,8 @@ export default function UserListItem(props: UserListItemProps) { const [openTokenForm, setOpenTokenForm] = React.useState(false); const [openGroupForm, setOpenGroupForm] = React.useState(false); + const { data: tokens, isLoading: tokensAreLoading } = useUserTokensQuery(login); + const { data: groupsCount, isLoading: groupsAreLoading } = useUserGroupsCountQuery(login); return ( <tr> @@ -75,28 +77,32 @@ export default function UserListItem(props: UserListItemProps) { <DateFromNow date={sonarLintLastConnectionDate ?? ''} hourPrecision /> </td> <td className="thin nowrap text-middle"> - {groupsCount} - {manageProvider === undefined && ( + <Spinner loading={groupsAreLoading}> + {groupsCount} + {manageProvider === undefined && ( + <ButtonIcon + aria-label={translateWithParameters('users.update_users_groups', user.login)} + className="js-user-groups spacer-left button-small" + onClick={() => setOpenGroupForm(true)} + tooltip={translate('users.update_groups')} + > + <BulletListIcon /> + </ButtonIcon> + )} + </Spinner> + </td> + <td className="thin nowrap text-middle"> + <Spinner loading={tokensAreLoading}> + {tokens?.length} <ButtonIcon - aria-label={translateWithParameters('users.update_users_groups', user.login)} - className="js-user-groups spacer-left button-small" - onClick={() => setOpenGroupForm(true)} - tooltip={translate('users.update_groups')} + className="js-user-tokens spacer-left button-small" + onClick={() => setOpenTokenForm(true)} + tooltip={translateWithParameters('users.update_tokens')} + aria-label={translateWithParameters('users.update_tokens_for_x', name ?? login)} > <BulletListIcon /> </ButtonIcon> - )} - </td> - <td className="thin nowrap text-middle"> - {tokensCount} - <ButtonIcon - className="js-user-tokens spacer-left button-small" - onClick={() => setOpenTokenForm(true)} - tooltip={translateWithParameters('users.update_tokens')} - aria-label={translateWithParameters('users.update_tokens_for_x', name ?? login)} - > - <BulletListIcon /> - </ButtonIcon> + </Spinner> </td> <td className="thin nowrap text-right text-middle"> diff --git a/server/sonar-web/src/main/js/helpers/testMocks.ts b/server/sonar-web/src/main/js/helpers/testMocks.ts index 5c59fb9e9f5..4b55fdf9281 100644 --- a/server/sonar-web/src/main/js/helpers/testMocks.ts +++ b/server/sonar-web/src/main/js/helpers/testMocks.ts @@ -714,8 +714,6 @@ export function mockRestUser(overrides: Partial<RestUserDetailed> = {}): RestUse sonarQubeLastConnectionDate: null, sonarLintLastConnectionDate: null, scmAccounts: [], - tokensCount: 0, - groupsCount: 0, avatar: 'buzzonthemoon', ...overrides, }; diff --git a/server/sonar-web/src/main/js/queries/users.ts b/server/sonar-web/src/main/js/queries/users.ts index 6b1a95e8243..38657ab82a2 100644 --- a/server/sonar-web/src/main/js/queries/users.ts +++ b/server/sonar-web/src/main/js/queries/users.ts @@ -22,10 +22,14 @@ import { QueryFunctionContext, useMutation, useQueries, + useQuery, useQueryClient, } from '@tanstack/react-query'; import { range } from 'lodash'; -import { deleteUser, getUsers, postUser, updateUser } from '../api/users'; +import { generateToken, getTokens, revokeToken } from '../api/user-tokens'; +import { addUserToGroup, removeUserFromGroup } from '../api/user_groups'; +import { deleteUser, getUserGroups, getUsers, postUser, updateUser } from '../api/users'; +import { UserToken } from '../types/token'; import { RestUserBase } from '../types/users'; const STALE_TIME = 4 * 60 * 1000; @@ -59,19 +63,27 @@ export function useUsersQueries<U extends RestUserBase>( ); } -export function useInvalidateUsersList() { - const queryClient = useQueryClient(); +export function useUserTokensQuery(login: string) { + return useQuery({ + queryKey: ['user', login, 'tokens'], + queryFn: () => getTokens(login), + staleTime: STALE_TIME, + }); +} - return () => queryClient.invalidateQueries({ queryKey: ['user', 'list'] }); +export function useUserGroupsCountQuery(login: string) { + return useQuery({ + queryKey: ['user', login, 'groups', 'total'], + queryFn: () => getUserGroups({ login, ps: 1 }).then((r) => r.paging.total), + staleTime: STALE_TIME, + }); } export function usePostUserMutation() { const queryClient = useQueryClient(); return useMutation({ - mutationFn: async (data: Parameters<typeof postUser>[0]) => { - await postUser(data); - }, + mutationFn: (data: Parameters<typeof postUser>[0]) => postUser(data), onSuccess() { queryClient.invalidateQueries({ queryKey: ['user', 'list'] }); }, @@ -82,9 +94,7 @@ export function useUpdateUserMutation() { const queryClient = useQueryClient(); return useMutation({ - mutationFn: async (data: Parameters<typeof updateUser>[0]) => { - await updateUser(data); - }, + mutationFn: (data: Parameters<typeof updateUser>[0]) => updateUser(data), onSuccess() { queryClient.invalidateQueries({ queryKey: ['user', 'list'] }); }, @@ -95,11 +105,67 @@ export function useDeactivateUserMutation() { const queryClient = useQueryClient(); return useMutation({ - mutationFn: async (data: Parameters<typeof deleteUser>[0]) => { - await deleteUser(data); - }, + mutationFn: (data: Parameters<typeof deleteUser>[0]) => deleteUser(data), onSuccess() { queryClient.invalidateQueries({ queryKey: ['user', 'list'] }); }, }); } + +export function useGenerateTokenMutation() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (data: Parameters<typeof generateToken>[0] & { projectName?: string }) => + generateToken(data), + onSuccess(data, variables) { + queryClient.setQueryData<UserToken[]>(['user', data.login, 'tokens'], (oldData) => { + const newData = { + ...data, + project: + variables.projectKey && variables.projectName + ? { key: variables.projectKey, name: variables.projectName } + : undefined, + }; + return oldData ? [...oldData, newData] : [newData]; + }); + }, + }); +} + +export function useRevokeTokenMutation() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (data: Parameters<typeof revokeToken>[0]) => revokeToken(data), + onSuccess(_, data) { + queryClient.setQueryData<UserToken[]>(['user', data.login, 'tokens'], (oldData) => + oldData ? oldData.filter((token) => token.name !== data.name) : undefined + ); + }, + }); +} + +export function useAddUserToGroupMutation() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (data: Parameters<typeof addUserToGroup>[0]) => addUserToGroup(data), + onSuccess(_, data) { + queryClient.setQueryData<number>(['user', data.login, 'groups', 'total'], (oldData) => + oldData !== undefined ? oldData + 1 : undefined + ); + }, + }); +} + +export function useRemoveUserToGroupMutation() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (data: Parameters<typeof removeUserFromGroup>[0]) => removeUserFromGroup(data), + onSuccess(_, data) { + queryClient.setQueryData<number>(['user', data.login, 'groups', 'total'], (oldData) => + oldData !== undefined ? oldData - 1 : undefined + ); + }, + }); +} diff --git a/server/sonar-web/src/main/js/types/users.ts b/server/sonar-web/src/main/js/types/users.ts index d7acd57f448..a556d6c1a7c 100644 --- a/server/sonar-web/src/main/js/types/users.ts +++ b/server/sonar-web/src/main/js/types/users.ts @@ -112,8 +112,6 @@ export interface RestUserDetailed extends RestUser { sonarQubeLastConnectionDate: string | null; sonarLintLastConnectionDate: string | null; scmAccounts: string[]; - groupsCount: number; - tokensCount: number; } export const enum ChangePasswordResults { |