aboutsummaryrefslogtreecommitdiffstats
path: root/server/sonar-web
diff options
context:
space:
mode:
authorViktor Vorona <viktor.vorona@sonarsource.com>2023-08-18 15:44:48 +0200
committersonartech <sonartech@sonarsource.com>2023-08-22 20:03:05 +0000
commit2d65c6839f5baf2e7c946d06683f00877546ad50 (patch)
tree3d84260ef15b71265ece62f1c2b9aa951eb310e7 /server/sonar-web
parenta8087e5f41a3797a236bbb49da20650e95c8cae0 (diff)
downloadsonarqube-2d65c6839f5baf2e7c946d06683f00877546ad50.tar.gz
sonarqube-2d65c6839f5baf2e7c946d06683f00877546ad50.zip
SONAR-20184 Remove groupsCount and tokensCount from user
Diffstat (limited to 'server/sonar-web')
-rw-r--r--server/sonar-web/src/main/js/api/mocks/UserTokensMock.ts8
-rw-r--r--server/sonar-web/src/main/js/api/mocks/UsersServiceMock.ts23
-rw-r--r--server/sonar-web/src/main/js/apps/account/__tests__/Account-it.tsx8
-rw-r--r--server/sonar-web/src/main/js/apps/users/__tests__/UsersApp-it.tsx22
-rw-r--r--server/sonar-web/src/main/js/apps/users/components/GroupsForm.tsx16
-rw-r--r--server/sonar-web/src/main/js/apps/users/components/TokensForm.tsx389
-rw-r--r--server/sonar-web/src/main/js/apps/users/components/TokensFormItem.tsx219
-rw-r--r--server/sonar-web/src/main/js/apps/users/components/TokensFormModal.tsx24
-rw-r--r--server/sonar-web/src/main/js/apps/users/components/UserListItem.tsx46
-rw-r--r--server/sonar-web/src/main/js/helpers/testMocks.ts2
-rw-r--r--server/sonar-web/src/main/js/queries/users.ts92
-rw-r--r--server/sonar-web/src/main/js/types/users.ts2
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 {