]> source.dussan.org Git - sonarqube.git/commitdiff
SONAR-19967 Use DELETE api/v2/users/:login on the FE
authorguillaume-peoch-sonarsource <guillaume.peoch@sonarsource.com>
Wed, 26 Jul 2023 14:49:26 +0000 (16:49 +0200)
committersonartech <sonartech@sonarsource.com>
Mon, 31 Jul 2023 20:03:32 +0000 (20:03 +0000)
18 files changed:
server/sonar-web/src/main/js/api/mocks/UsersServiceMock.ts
server/sonar-web/src/main/js/api/queries/users.ts [deleted file]
server/sonar-web/src/main/js/api/users.ts
server/sonar-web/src/main/js/apps/users/UsersApp.tsx
server/sonar-web/src/main/js/apps/users/UsersList.tsx
server/sonar-web/src/main/js/apps/users/__tests__/UsersApp-it.tsx
server/sonar-web/src/main/js/apps/users/components/DeactivateForm.tsx
server/sonar-web/src/main/js/apps/users/components/GroupsForm.tsx
server/sonar-web/src/main/js/apps/users/components/PasswordForm.tsx
server/sonar-web/src/main/js/apps/users/components/TokensFormModal.tsx
server/sonar-web/src/main/js/apps/users/components/UserActions.tsx
server/sonar-web/src/main/js/apps/users/components/UserForm.tsx
server/sonar-web/src/main/js/apps/users/components/UserListItem.tsx
server/sonar-web/src/main/js/apps/users/components/UserListItemIdentity.tsx
server/sonar-web/src/main/js/helpers/request.ts
server/sonar-web/src/main/js/helpers/testMocks.ts
server/sonar-web/src/main/js/queries/users.ts [new file with mode: 0644]
server/sonar-web/src/main/js/types/users.ts

index 71f4e22684773fb844783ab9d5db1ab5133e5814..decdb9c4d5385c9d5bbd6afed0aa580a1db8d85b 100644 (file)
 import { isAfter, isBefore } from 'date-fns';
 import { cloneDeep, isEmpty, isUndefined, omitBy } from 'lodash';
 import { mockClusterSysInfo, mockIdentityProvider, mockRestUser } from '../../helpers/testMocks';
-import { IdentityProvider, Paging, SysInfoCluster } from '../../types/types';
-import { ChangePasswordResults, User } from '../../types/users';
+import { IdentityProvider, SysInfoCluster } from '../../types/types';
+import { ChangePasswordResults, RestUserDetailed, User } from '../../types/users';
 import { getSystemInfo } from '../system';
 import { addUserToGroup, removeUserFromGroup } from '../user_groups';
 import {
-  GetUsersParams,
-  RestUser,
   UserGroup,
   changePassword,
   createUser,
-  deactivateUser,
+  deleteUser,
   getIdentityProviders,
   getUserGroups,
   getUsers,
@@ -133,7 +131,7 @@ export default class UsersServiceMock {
     jest.mocked(addUserToGroup).mockImplementation(this.handleAddUserToGroup);
     jest.mocked(removeUserFromGroup).mockImplementation(this.handleRemoveUserFromGroup);
     jest.mocked(changePassword).mockImplementation(this.handleChangePassword);
-    jest.mocked(deactivateUser).mockImplementation(this.handleDeactivateUser);
+    jest.mocked(deleteUser).mockImplementation(this.handleDeactivateUser);
   }
 
   setIsManaged(managed: boolean) {
@@ -208,9 +206,7 @@ export default class UsersServiceMock {
     });
   };
 
-  handleGetUsers = (
-    data: GetUsersParams
-  ): Promise<{ pageRestResponse: Paging; users: RestUser<'admin'>[] }> => {
+  handleGetUsers: typeof getUsers<RestUserDetailed> = (data) => {
     let pageRestResponse = {
       pageIndex: 1,
       pageSize: 0,
@@ -240,7 +236,7 @@ export default class UsersServiceMock {
       sonarQubeLastConnectionDateTo: data.sonarQubeLastConnectionDateTo,
       sonarLintLastConnectionDateFrom: data.sonarLintLastConnectionDateFrom,
       sonarLintLastConnectionDateTo: data.sonarLintLastConnectionDateTo,
-    }) as RestUser<'admin'>[];
+    });
 
     return this.reply({
       pageRestResponse: {
@@ -373,7 +369,7 @@ export default class UsersServiceMock {
     return this.reply({});
   };
 
-  handleDeactivateUser: typeof deactivateUser = (data) => {
+  handleDeactivateUser: typeof deleteUser = (data) => {
     const index = this.users.findIndex((u) => u.login === data.login);
     const user = this.users.splice(index, 1)[0];
     user.active = false;
diff --git a/server/sonar-web/src/main/js/api/queries/users.ts b/server/sonar-web/src/main/js/api/queries/users.ts
deleted file mode 100644 (file)
index bc18ed5..0000000
+++ /dev/null
@@ -1,107 +0,0 @@
-/*
- * SonarQube
- * Copyright (C) 2009-2023 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 {
-  QueryFunctionContext,
-  useMutation,
-  useQueries,
-  useQueryClient,
-} from '@tanstack/react-query';
-import { range } from 'lodash';
-import {
-  CreateUserParams,
-  DeactivateUserParams,
-  GetUsersParams,
-  Permission,
-  RestUser,
-  UpdateUserParams,
-  createUser,
-  deactivateUser,
-  getUsers,
-  updateUser,
-} from '../users';
-
-export function useUsersQueries<P extends Permission>(
-  getParams: Omit<GetUsersParams, 'pageSize' | 'pageIndex'>,
-  numberOfPages: number
-) {
-  type QueryKey = ['user', 'list', number, Omit<GetUsersParams, 'pageSize' | 'pageIndex'>];
-  const results = useQueries({
-    queries: range(1, numberOfPages + 1).map((page: number) => ({
-      queryKey: ['user', 'list', page, getParams],
-      queryFn: ({ queryKey: [_u, _l, page, getParams] }: QueryFunctionContext<QueryKey>) =>
-        getUsers<P>({ ...getParams, pageIndex: page }),
-    })),
-  });
-
-  return results.reduce(
-    (acc, { data, isLoading }) => ({
-      users: acc.users.concat(data?.users ?? []),
-      total: data?.pageRestResponse.total,
-      isLoading: acc.isLoading || isLoading,
-    }),
-    { users: [] as RestUser<P>[], total: 0, isLoading: false }
-  );
-}
-
-export function useInvalidateUsersList() {
-  const queryClient = useQueryClient();
-
-  return () => queryClient.invalidateQueries({ queryKey: ['user', 'list'] });
-}
-
-export function useCreateUserMutation() {
-  const queryClient = useQueryClient();
-
-  return useMutation({
-    mutationFn: async (data: CreateUserParams) => {
-      await createUser(data);
-    },
-    onSuccess() {
-      queryClient.invalidateQueries({ queryKey: ['user', 'list'] });
-    },
-  });
-}
-
-export function useUpdateUserMutation() {
-  const queryClient = useQueryClient();
-
-  return useMutation({
-    mutationFn: async (data: UpdateUserParams) => {
-      await updateUser(data);
-    },
-    onSuccess() {
-      queryClient.invalidateQueries({ queryKey: ['user', 'list'] });
-    },
-  });
-}
-
-export function useDeactivateUserMutation() {
-  const queryClient = useQueryClient();
-
-  return useMutation({
-    mutationFn: async (data: DeactivateUserParams) => {
-      await deactivateUser(data);
-    },
-    onSuccess() {
-      queryClient.invalidateQueries({ queryKey: ['user', 'list'] });
-    },
-  });
-}
index 72c569b8dda2ac2f01cbe564ede011e077ef39a1..d75c811ff95a281e65702e67bd83d27e67f8db09 100644 (file)
  * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
  */
 import { throwGlobalError } from '../helpers/error';
-import { getJSON, HttpStatus, parseJSON, post, postJSON } from '../helpers/request';
+import { deleteJSON, getJSON, HttpStatus, parseJSON, post, postJSON } from '../helpers/request';
 import { IdentityProvider, Paging } from '../types/types';
-import { ChangePasswordResults, CurrentUser, HomePage, NoticeType, User } from '../types/users';
+import {
+  ChangePasswordResults,
+  CurrentUser,
+  HomePage,
+  NoticeType,
+  RestUserBase,
+  RestUserDetailed,
+  User,
+} from '../types/users';
 
 export function getCurrentUser(): Promise<CurrentUser> {
   return getJSON('/api/users/current', undefined, true);
@@ -53,17 +61,13 @@ export interface UserGroup {
   selected: boolean;
 }
 
-export interface UserGroupsParams {
+export function getUserGroups(data: {
   login: string;
   p?: number;
   ps?: number;
   q?: string;
   selected?: string;
-}
-
-export function getUserGroups(
-  data: UserGroupsParams
-): Promise<{ paging: Paging; groups: UserGroup[] }> {
+}): Promise<{ paging: Paging; groups: UserGroup[] }> {
   return getJSON('/api/users/groups', data);
 }
 
@@ -71,7 +75,7 @@ export function getIdentityProviders(): Promise<{ identityProviders: IdentityPro
   return getJSON('/api/users/identity_providers').catch(throwGlobalError);
 }
 
-export interface SearchUsersParams {
+export function searchUsers(data: {
   p?: number;
   ps?: number;
   q?: string;
@@ -80,14 +84,12 @@ export interface SearchUsersParams {
   lastConnectedBefore?: string;
   slLastConnectedAfter?: string;
   slLastConnectedBefore?: string;
-}
-
-export function searchUsers(data: SearchUsersParams): Promise<{ paging: Paging; users: User[] }> {
+}): Promise<{ paging: Paging; users: User[] }> {
   data.q = data.q || undefined;
   return getJSON('/api/users/search', data).catch(throwGlobalError);
 }
 
-export interface GetUsersParams {
+export function getUsers<T extends RestUserBase>(data: {
   q: string;
   active?: boolean;
   managed?: boolean;
@@ -97,81 +99,41 @@ export interface GetUsersParams {
   sonarLintLastConnectionDateTo?: string;
   pageSize?: number;
   pageIndex?: number;
-}
-
-export type Permission = 'admin' | 'anonymous' | 'user';
-
-export type RestUser<T extends Permission> = T extends 'admin'
-  ? {
-      id: string;
-      login: string;
-      name: string;
-      email: string;
-      active: boolean;
-      local: boolean;
-      externalProvider: string;
-      avatar: string;
-      managed: boolean;
-      externalLogin: string;
-      sonarQubeLastConnectionDate: string | null;
-      sonarLintLastConnectionDate: string | null;
-      scmAccounts: string[];
-      groupsCount: number;
-      tokensCount: number;
-    }
-  : T extends 'anonymous'
-  ? { id: string; login: string; name: string }
-  : {
-      id: string;
-      login: string;
-      name: string;
-      email: string;
-      active: boolean;
-      local: boolean;
-      externalProvider: string;
-      avatar: string;
-    };
-
-export function getUsers<T extends Permission>(
-  data: GetUsersParams
-): Promise<{ pageRestResponse: Paging; users: RestUser<T>[] }> {
+}): Promise<{ pageRestResponse: Paging; users: T[] }> {
   return getJSON('/api/v2/users', data).catch(throwGlobalError);
 }
 
-export interface CreateUserParams {
+export function createUser(data: {
   email?: string;
   local?: boolean;
   login: string;
   name: string;
   password?: string;
   scmAccount: string[];
-}
-
-export function createUser(data: CreateUserParams): Promise<void | Response> {
+}): Promise<void | Response> {
   return post('/api/users/create', data);
 }
 
-export interface UpdateUserParams {
+export function updateUser(data: {
   email?: string;
   login: string;
   name?: string;
   scmAccount: string[];
-}
-
-export function updateUser(data: UpdateUserParams): Promise<{ user: User }> {
+}): Promise<{ user: User }> {
   return postJSON('/api/users/update', {
     ...data,
     scmAccount: data.scmAccount.length > 0 ? data.scmAccount : '',
   });
 }
 
-export interface DeactivateUserParams {
+export function deleteUser({
+  login,
+  anonymize,
+}: {
   login: string;
   anonymize?: boolean;
-}
-
-export function deactivateUser(data: DeactivateUserParams): Promise<{ user: RestUser<'admin'> }> {
-  return postJSON('/api/users/deactivate', data).catch(throwGlobalError);
+}): Promise<{ user: RestUserDetailed }> {
+  return deleteJSON(`/api/v2/users/${login}`, { anonymize }).catch(throwGlobalError);
 }
 
 export function setHomePage(homepage: HomePage): Promise<void | Response> {
index e087a7dd8e218259cce721745782d293ace82ad7..7ba7969debb98c57454897b21e8f5963529fa986 100644 (file)
@@ -20,7 +20,6 @@
 import { subDays, subSeconds } from 'date-fns';
 import React, { useEffect, useMemo, useState } from 'react';
 import { Helmet } from 'react-helmet-async';
-import { useUsersQueries } from '../../api/queries/users';
 import { getIdentityProviders } from '../../api/users';
 import GitHubSynchronisationWarning from '../../app/components/GitHubSynchronisationWarning';
 import HelpTooltip from '../../components/controls/HelpTooltip';
@@ -33,7 +32,9 @@ import { Provider, useManageProvider } from '../../components/hooks/useManagePro
 import DeferredSpinner from '../../components/ui/DeferredSpinner';
 import { now, toISO8601WithOffsetString } from '../../helpers/dates';
 import { translate } from '../../helpers/l10n';
+import { useUsersQueries } from '../../queries/users';
 import { IdentityProvider } from '../../types/types';
+import { RestUserDetailed } from '../../types/users';
 import Header from './Header';
 import UsersList from './UsersList';
 import { USERS_ACTIVITY_OPTIONS, USER_INACTIVITY_DAYS_THRESHOLD } from './constants';
@@ -70,7 +71,7 @@ export default function UsersApp() {
     }
   }, [usersActivity]);
 
-  const { users, total, isLoading } = useUsersQueries<'admin'>(
+  const { users, total, isLoading } = useUsersQueries<RestUserDetailed>(
     {
       q: search,
       managed,
index c3d01379d49d73425287bb4d4c62304497e1635f..33ce5102cd8eed32de938d89034e9c766afadd7f 100644 (file)
  * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
  */
 import * as React from 'react';
-import { RestUser } from '../../api/users';
-import { CurrentUserContext } from '../../app/components/current-user/CurrentUserContext';
 import HelpTooltip from '../../components/controls/HelpTooltip';
 import { translate } from '../../helpers/l10n';
 import { IdentityProvider } from '../../types/types';
-import { isLoggedIn } from '../../types/users';
+import { RestUserDetailed } from '../../types/users';
 import UserListItem from './components/UserListItem';
 
 interface Props {
   identityProviders: IdentityProvider[];
-  users: RestUser<'admin'>[];
+  users: RestUserDetailed[];
   manageProvider: string | undefined;
 }
 
 export default function UsersList({ identityProviders, users, manageProvider }: Props) {
-  const userContext = React.useContext(CurrentUserContext);
-  const currentUser = userContext?.currentUser;
-
   return (
     <div className="boxed-group boxed-group-inner">
       <table className="data zebra" id="users-list">
@@ -64,7 +59,6 @@ export default function UsersList({ identityProviders, users, manageProvider }:
               identityProvider={identityProviders.find(
                 (provider) => user.externalProvider === provider.key
               )}
-              isCurrentUser={isLoggedIn(currentUser) && currentUser.login === user.login}
               key={user.login}
               user={user}
               manageProvider={manageProvider}
index dc7b33c2f8578a1cdbab68c11fa8f7ea94b5e4a5..73e454c64a3d225a481a925a52dd83a8c519f4a2 100644 (file)
@@ -592,7 +592,6 @@ it('should render external identity Providers', async () => {
   renderUsersApp();
 
   await act(async () => expect(await ui.charlieRow.find()).toHaveTextContent(/ExternalTest/));
-  // logRoles(document.body);
   expect(await ui.denisRow.find()).toHaveTextContent(/test2: UnknownExternalProvider/);
 });
 
index aa334af4bb49555f217e5f071742e6d1475d4e39..b7fbf2077bf4c5bc7897cd2ded6415aa37185edb 100644 (file)
  */
 import * as React from 'react';
 import { FormattedMessage } from 'react-intl';
-import { useDeactivateUserMutation } from '../../../api/queries/users';
 import DocLink from '../../../components/common/DocLink';
 import Checkbox from '../../../components/controls/Checkbox';
 import Modal from '../../../components/controls/Modal';
 import { ResetButtonLink, SubmitButton } from '../../../components/controls/buttons';
 import { Alert } from '../../../components/ui/Alert';
 import { translate, translateWithParameters } from '../../../helpers/l10n';
+import { useDeactivateUserMutation } from '../../../queries/users';
 import { UserActive } from '../../../types/users';
 
 export interface Props {
index fda16770551735648055d0f78a201aa969c56f6c..e27d00ec0d34462eb627b0fefc25f7a2ec57e573 100644 (file)
@@ -19,9 +19,8 @@
  */
 import { find, without } from 'lodash';
 import * as React from 'react';
-import { useInvalidateUsersList } from '../../../api/queries/users';
 import { addUserToGroup, removeUserFromGroup } from '../../../api/user_groups';
-import { RestUser, UserGroup, getUserGroups } from '../../../api/users';
+import { UserGroup, getUserGroups } from '../../../api/users';
 import Modal from '../../../components/controls/Modal';
 import SelectList, {
   SelectListFilter,
@@ -29,10 +28,12 @@ import SelectList, {
 } from '../../../components/controls/SelectList';
 import { ResetButtonLink } from '../../../components/controls/buttons';
 import { translate } from '../../../helpers/l10n';
+import { useInvalidateUsersList } from '../../../queries/users';
+import { RestUserDetailed } from '../../../types/users';
 
 interface Props {
   onClose: () => void;
-  user: RestUser<'admin'>;
+  user: RestUserDetailed;
 }
 
 export default function GroupsForm(props: Props) {
index 0387363c3c6849c86311ab29c8e93b43bba9f970..6e9a17d32375288a2dc60b8fa22ec0b52c8403c3 100644 (file)
@@ -18,7 +18,8 @@
  * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
  */
 import * as React from 'react';
-import { RestUser, changePassword } from '../../../api/users';
+import { changePassword } from '../../../api/users';
+import { CurrentUserContext } from '../../../app/components/current-user/CurrentUserContext';
 import Modal from '../../../components/controls/Modal';
 import { ResetButtonLink, SubmitButton } from '../../../components/controls/buttons';
 import { Alert } from '../../../components/ui/Alert';
@@ -26,151 +27,124 @@ import MandatoryFieldMarker from '../../../components/ui/MandatoryFieldMarker';
 import MandatoryFieldsExplanation from '../../../components/ui/MandatoryFieldsExplanation';
 import { addGlobalSuccessMessage } from '../../../helpers/globalMessages';
 import { translate } from '../../../helpers/l10n';
-import { ChangePasswordResults } from '../../../types/users';
+import { ChangePasswordResults, RestUserDetailed, isLoggedIn } from '../../../types/users';
 
 interface Props {
-  isCurrentUser: boolean;
   onClose: () => void;
-  user: RestUser<'admin'>;
+  user: RestUserDetailed;
 }
 
-interface State {
-  confirmPassword: string;
-  errorTranslationKey?: string;
-  newPassword: string;
-  oldPassword: string;
-  submitting: boolean;
-}
-
-export default class PasswordForm extends React.PureComponent<Props, State> {
-  mounted = false;
-  state: State = {
-    confirmPassword: '',
-    newPassword: '',
-    oldPassword: '',
-    submitting: false,
-  };
+export default function PasswordForm(props: Props) {
+  const { user } = props;
+  const [confirmPassword, setConfirmPassword] = React.useState('');
+  const [errorTranslationKey, setErrorTranslationKey] = React.useState<string | undefined>(
+    undefined
+  );
+  const [newPassword, setNewPassword] = React.useState('');
+  const [oldPassword, setOldPassword] = React.useState('');
+  const [submitting, setSubmitting] = React.useState(false);
 
-  componentDidMount() {
-    this.mounted = true;
-  }
+  const userContext = React.useContext(CurrentUserContext);
+  const currentUser = userContext?.currentUser;
+  const isCurrentUser = isLoggedIn(currentUser) && currentUser.login === user.login;
 
-  componentWillUnmount() {
-    this.mounted = false;
-  }
-
-  handleError = (result: ChangePasswordResults) => {
-    if (this.mounted) {
-      if (result === ChangePasswordResults.OldPasswordIncorrect) {
-        this.setState({ errorTranslationKey: 'user.old_password_incorrect', submitting: false });
-      } else if (result === ChangePasswordResults.NewPasswordSameAsOld) {
-        this.setState({ errorTranslationKey: 'user.new_password_same_as_old', submitting: false });
-      }
+  const handleError = (result: ChangePasswordResults) => {
+    if (result === ChangePasswordResults.OldPasswordIncorrect) {
+      setErrorTranslationKey('user.old_password_incorrect');
+      setSubmitting(false);
+    } else if (result === ChangePasswordResults.NewPasswordSameAsOld) {
+      setErrorTranslationKey('user.new_password_same_as_old');
+      setSubmitting(false);
     }
   };
 
-  handleConfirmPasswordChange = (event: React.SyntheticEvent<HTMLInputElement>) =>
-    this.setState({ confirmPassword: event.currentTarget.value });
-
-  handleNewPasswordChange = (event: React.SyntheticEvent<HTMLInputElement>) =>
-    this.setState({ newPassword: event.currentTarget.value });
-
-  handleOldPasswordChange = (event: React.SyntheticEvent<HTMLInputElement>) =>
-    this.setState({ oldPassword: event.currentTarget.value });
-
-  handleChangePassword = (event: React.SyntheticEvent<HTMLFormElement>) => {
+  const handleChangePassword = (event: React.SyntheticEvent<HTMLFormElement>) => {
     event.preventDefault();
-    if (
-      this.state.newPassword.length > 0 &&
-      this.state.newPassword === this.state.confirmPassword
-    ) {
-      this.setState({ submitting: true });
+    if (newPassword.length > 0 && newPassword === confirmPassword) {
+      setSubmitting(true);
       changePassword({
-        login: this.props.user.login,
-        password: this.state.newPassword,
-        previousPassword: this.state.oldPassword,
+        login: user.login,
+        password: newPassword,
+        previousPassword: oldPassword,
       }).then(() => {
         addGlobalSuccessMessage(translate('my_profile.password.changed'));
-        this.props.onClose();
-      }, this.handleError);
+        props.onClose();
+      }, handleError);
     }
   };
 
-  render() {
-    const { errorTranslationKey, submitting, newPassword, confirmPassword } = this.state;
+  const header = translate('my_profile.password.title');
 
-    const header = translate('my_profile.password.title');
-    return (
-      <Modal contentLabel={header} onRequestClose={this.props.onClose} size="small">
-        <form autoComplete="off" id="user-password-form" onSubmit={this.handleChangePassword}>
-          <header className="modal-head">
-            <h2>{header}</h2>
-          </header>
-          <div className="modal-body">
-            {errorTranslationKey && <Alert variant="error">{translate(errorTranslationKey)}</Alert>}
+  return (
+    <Modal contentLabel={header} onRequestClose={props.onClose} size="small">
+      <form autoComplete="off" id="user-password-form" onSubmit={handleChangePassword}>
+        <header className="modal-head">
+          <h2>{header}</h2>
+        </header>
+        <div className="modal-body">
+          {errorTranslationKey && <Alert variant="error">{translate(errorTranslationKey)}</Alert>}
 
-            <MandatoryFieldsExplanation className="modal-field" />
+          <MandatoryFieldsExplanation className="modal-field" />
 
-            {this.props.isCurrentUser && (
-              <div className="modal-field">
-                <label htmlFor="old-user-password">
-                  {translate('my_profile.password.old')}
-                  <MandatoryFieldMarker />
-                </label>
-                {/* keep this fake field to hack browser autofill */}
-                <input className="hidden" aria-hidden name="old-password-fake" type="password" />
-                <input
-                  id="old-user-password"
-                  name="old-password"
-                  onChange={this.handleOldPasswordChange}
-                  required
-                  type="password"
-                  value={this.state.oldPassword}
-                />
-              </div>
-            )}
+          {isCurrentUser && (
             <div className="modal-field">
-              <label htmlFor="user-password">
-                {translate('my_profile.password.new')}
+              <label htmlFor="old-user-password">
+                {translate('my_profile.password.old')}
                 <MandatoryFieldMarker />
               </label>
               {/* keep this fake field to hack browser autofill */}
-              <input className="hidden" aria-hidden name="password-fake" type="password" />
+              <input className="hidden" aria-hidden name="old-password-fake" type="password" />
               <input
-                id="user-password"
-                name="password"
-                onChange={this.handleNewPasswordChange}
+                id="old-user-password"
+                name="old-password"
+                onChange={(event) => setOldPassword(event.currentTarget.value)}
                 required
                 type="password"
-                value={this.state.newPassword}
-              />
-            </div>
-            <div className="modal-field">
-              <label htmlFor="confirm-user-password">
-                {translate('my_profile.password.confirm')}
-                <MandatoryFieldMarker />
-              </label>
-              {/* keep this fake field to hack browser autofill */}
-              <input className="hidden" aria-hidden name="confirm-password-fake" type="password" />
-              <input
-                id="confirm-user-password"
-                name="confirm-password"
-                onChange={this.handleConfirmPasswordChange}
-                required
-                type="password"
-                value={this.state.confirmPassword}
+                value={oldPassword}
               />
             </div>
+          )}
+          <div className="modal-field">
+            <label htmlFor="user-password">
+              {translate('my_profile.password.new')}
+              <MandatoryFieldMarker />
+            </label>
+            {/* keep this fake field to hack browser autofill */}
+            <input className="hidden" aria-hidden name="password-fake" type="password" />
+            <input
+              id="user-password"
+              name="password"
+              onChange={(event) => setNewPassword(event.currentTarget.value)}
+              required
+              type="password"
+              value={newPassword}
+            />
+          </div>
+          <div className="modal-field">
+            <label htmlFor="confirm-user-password">
+              {translate('my_profile.password.confirm')}
+              <MandatoryFieldMarker />
+            </label>
+            {/* keep this fake field to hack browser autofill */}
+            <input className="hidden" aria-hidden name="confirm-password-fake" type="password" />
+            <input
+              id="confirm-user-password"
+              name="confirm-password"
+              onChange={(event) => setConfirmPassword(event.currentTarget.value)}
+              required
+              type="password"
+              value={confirmPassword}
+            />
           </div>
-          <footer className="modal-foot">
-            {submitting && <i className="spinner spacer-right" />}
-            <SubmitButton disabled={submitting || !newPassword || newPassword !== confirmPassword}>
-              {translate('change_verb')}
-            </SubmitButton>
-            <ResetButtonLink onClick={this.props.onClose}>{translate('cancel')}</ResetButtonLink>
-          </footer>
-        </form>
-      </Modal>
-    );
-  }
+        </div>
+        <footer className="modal-foot">
+          {submitting && <i className="spinner spacer-right" />}
+          <SubmitButton disabled={submitting || !newPassword || newPassword !== confirmPassword}>
+            {translate('change_verb')}
+          </SubmitButton>
+          <ResetButtonLink onClick={props.onClose}>{translate('cancel')}</ResetButtonLink>
+        </footer>
+      </form>
+    </Modal>
+  );
 }
index 87c219710f76a65c49d993e6c3383eda59b5470a..0524be649a78321f6e51c4abdba65eac00b37d02 100644 (file)
  */
 import * as React from 'react';
 import { FormattedMessage } from 'react-intl';
-import { useInvalidateUsersList } from '../../../api/queries/users';
-import { RestUser } from '../../../api/users';
 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';
 
 interface Props {
-  user: RestUser<'admin'>;
+  user: RestUserDetailed;
   onClose: () => void;
 }
 
index aa61b5501949b49c0cc88a23bbe7a6948f84acb3..8d9937de08b3b6728919234d8901ea0e475f6c89 100644 (file)
  * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
  */
 import * as React from 'react';
-import { RestUser } from '../../../api/users';
 import ActionsDropdown, {
   ActionsDropdownDivider,
   ActionsDropdownItem,
 } from '../../../components/controls/ActionsDropdown';
 import { translate, translateWithParameters } from '../../../helpers/l10n';
-import { isUserActive } from '../../../types/users';
+import { RestUserDetailed, isUserActive } from '../../../types/users';
 import DeactivateForm from './DeactivateForm';
 import PasswordForm from './PasswordForm';
 import UserForm from './UserForm';
 
 interface Props {
-  isCurrentUser: boolean;
-  user: RestUser<'admin'>;
+  user: RestUserDetailed;
   manageProvider: string | undefined;
 }
 
 export default function UserActions(props: Props) {
-  const { isCurrentUser, user, manageProvider } = props;
+  const { user, manageProvider } = props;
 
   const [openForm, setOpenForm] = React.useState<string | undefined>(undefined);
 
@@ -90,11 +88,7 @@ export default function UserActions(props: Props) {
         <DeactivateForm onClose={() => setOpenForm(undefined)} user={user} />
       )}
       {openForm === 'password' && (
-        <PasswordForm
-          isCurrentUser={isCurrentUser}
-          onClose={() => setOpenForm(undefined)}
-          user={user}
-        />
+        <PasswordForm onClose={() => setOpenForm(undefined)} user={user} />
       )}
       {openForm === 'update' && <UserForm onClose={() => setOpenForm(undefined)} user={user} />}
     </>
index fc18984a583ccc4bbc699edeb3696b6f38212527..b63ad7288eef6f036ff81d8d05cae010516cc393 100644 (file)
@@ -18,8 +18,6 @@
  * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
  */
 import * as React from 'react';
-import { useCreateUserMutation, useUpdateUserMutation } from '../../../api/queries/users';
-import { RestUser } from '../../../api/users';
 import SimpleModal from '../../../components/controls/SimpleModal';
 import { Button, ResetButtonLink, SubmitButton } from '../../../components/controls/buttons';
 import { Alert } from '../../../components/ui/Alert';
@@ -28,28 +26,33 @@ import MandatoryFieldsExplanation from '../../../components/ui/MandatoryFieldsEx
 import { throwGlobalError } from '../../../helpers/error';
 import { translate, translateWithParameters } from '../../../helpers/l10n';
 import { parseError } from '../../../helpers/request';
+import { useCreateUserMutation, useUpdateUserMutation } from '../../../queries/users';
+import { RestUserDetailed } from '../../../types/users';
 import UserScmAccountInput from './UserScmAccountInput';
 
 export interface Props {
   onClose: () => void;
-  user?: RestUser<'admin'>;
+  user?: RestUserDetailed;
 }
 
+const BAD_REQUEST = 400;
+const INTERNAL_SERVER_ERROR = 500;
+
 export default function UserForm(props: Props) {
   const { user } = props;
 
   const { mutate: createUser } = useCreateUserMutation();
   const { mutate: updateUser } = useUpdateUserMutation();
 
-  const [email, setEmail] = React.useState<string>(user?.email || '');
-  const [login, setLogin] = React.useState<string>(user?.login || '');
-  const [name, setName] = React.useState<string>(user?.name || '');
+  const [email, setEmail] = React.useState<string>(user?.email ?? '');
+  const [login, setLogin] = React.useState<string>(user?.login ?? '');
+  const [name, setName] = React.useState<string>(user?.name ?? '');
   const [password, setPassword] = React.useState<string>('');
-  const [scmAccounts, setScmAccounts] = React.useState<string[]>(user?.scmAccounts || []);
+  const [scmAccounts, setScmAccounts] = React.useState<string[]>(user?.scmAccounts ?? []);
   const [error, setError] = React.useState<string | undefined>(undefined);
 
   const handleError = (response: Response) => {
-    if (![400, 500].includes(response.status)) {
+    if (![BAD_REQUEST, INTERNAL_SERVER_ERROR].includes(response.status)) {
       throwGlobalError(response);
     } else {
       parseError(response).then((errorMsg) => setError(errorMsg), throwGlobalError);
@@ -74,9 +77,9 @@ export default function UserForm(props: Props) {
 
     updateUser(
       {
-        email: user!.local ? email : undefined,
+        email: user?.local ? email : undefined,
         login,
-        name: user!.local ? name : undefined,
+        name: user?.local ? name : undefined,
         scmAccount: scmAccounts,
       },
       { onSuccess: props.onClose, onError: handleError }
index e5c6a7f4c885e5b5683aedc0cb6a17495bc30217..e0f314eb7429badb125f640ac0132f4aa51f7249 100644 (file)
  * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
  */
 import * as React from 'react';
-import { RestUser } from '../../../api/users';
 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 { translate, translateWithParameters } from '../../../helpers/l10n';
 import { IdentityProvider } from '../../../types/types';
+import { RestUserDetailed } from '../../../types/users';
 import GroupsForm from './GroupsForm';
 import TokensFormModal from './TokensFormModal';
 import UserActions from './UserActions';
@@ -33,13 +33,12 @@ import UserScmAccounts from './UserScmAccounts';
 
 export interface UserListItemProps {
   identityProvider?: IdentityProvider;
-  isCurrentUser: boolean;
-  user: RestUser<'admin'>;
+  user: RestUserDetailed;
   manageProvider: string | undefined;
 }
 
 export default function UserListItem(props: UserListItemProps) {
-  const { identityProvider, user, manageProvider, isCurrentUser } = props;
+  const { identityProvider, user, manageProvider } = props;
   const {
     name,
     login,
@@ -103,7 +102,7 @@ export default function UserListItem(props: UserListItemProps) {
 
       {(manageProvider === undefined || !managed) && (
         <td className="thin nowrap text-right text-middle">
-          <UserActions isCurrentUser={isCurrentUser} user={user} manageProvider={manageProvider} />
+          <UserActions user={user} manageProvider={manageProvider} />
         </td>
       )}
 
index e21060242ebe8c765bf93403a351c869f6f4f725..c432e59b62348d07a664a8cc2833aa107bff77d5 100644 (file)
 
 import { getTextColor } from 'design-system';
 import * as React from 'react';
-import { RestUser } from '../../../api/users';
 import { colors } from '../../../app/theme';
 import { translate } from '../../../helpers/l10n';
 import { getBaseUrl } from '../../../helpers/system';
 import { IdentityProvider } from '../../../types/types';
+import { RestUserDetailed } from '../../../types/users';
 
 export interface Props {
   identityProvider?: IdentityProvider;
-  user: RestUser<'admin'>;
+  user: RestUserDetailed;
   manageProvider?: string;
 }
 
index ff58851874fbba9448ff89c0dbca4f929e263c90..9b40c1f14ed5eee7b1b2678f52acf28fba288d5d 100644 (file)
@@ -272,6 +272,17 @@ export function post(url: string, data?: RequestData, bypassRedirect?: boolean):
   });
 }
 
+/**
+ * Shortcut to do a DELETE request
+ */
+export function deleteJSON(url: string, data?: RequestData): Promise<any> {
+  return request(url)
+    .setMethod('DELETE')
+    .setData(data)
+    .submit()
+    .then((response) => checkStatus(response));
+}
+
 function tryRequestAgain<T>(
   repeatAPICall: () => Promise<T>,
   tries: { max: number; slowThreshold: number },
index c3ee78cb3a4228d40f197a1385e3f540fd706755..474e43af88143a6fc0350dafc74381203f85e211 100644 (file)
@@ -19,7 +19,6 @@
  */
 import { To } from 'react-router-dom';
 import { CompareResponse } from '../api/quality-profiles';
-import { RestUser } from '../api/users';
 import { RuleDescriptionSections } from '../apps/coding-rules/rule';
 import { Exporter, Profile, ProfileChangelogEvent } from '../apps/quality-profiles/types';
 import { LogsLevels } from '../apps/system/utils';
@@ -57,7 +56,7 @@ import {
   UserGroupMember,
   UserSelected,
 } from '../types/types';
-import { CurrentUser, LoggedInUser, User } from '../types/users';
+import { CurrentUser, LoggedInUser, RestUserDetailed, User } from '../types/users';
 
 export function mockAlmApplication(overrides: Partial<AlmApplication> = {}): AlmApplication {
   return {
@@ -681,7 +680,7 @@ export function mockUser(overrides: Partial<User> = {}): User {
   };
 }
 
-export function mockRestUser(overrides: Partial<RestUser<'admin'>> = {}): RestUser<'admin'> {
+export function mockRestUser(overrides: Partial<RestUserDetailed> = {}): RestUserDetailed {
   return {
     id: Math.random().toString(),
     login: 'buzz.aldrin',
diff --git a/server/sonar-web/src/main/js/queries/users.ts b/server/sonar-web/src/main/js/queries/users.ts
new file mode 100644 (file)
index 0000000..51e3ce0
--- /dev/null
@@ -0,0 +1,105 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 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 {
+  QueryFunctionContext,
+  useMutation,
+  useQueries,
+  useQueryClient,
+} from '@tanstack/react-query';
+import { range } from 'lodash';
+import { createUser, deleteUser, getUsers, updateUser } from '../api/users';
+import { RestUserBase } from '../types/users';
+
+const STALE_TIME = 4 * 60 * 1000;
+
+export function useUsersQueries<U extends RestUserBase>(
+  getParams: Omit<Parameters<typeof getUsers>[0], 'pageSize' | 'pageIndex'>,
+  numberOfPages: number
+) {
+  type QueryKey = [
+    'user',
+    'list',
+    number,
+    Omit<Parameters<typeof getUsers>[0], 'pageSize' | 'pageIndex'>
+  ];
+  const results = useQueries({
+    queries: range(1, numberOfPages + 1).map((page: number) => ({
+      queryKey: ['user', 'list', page, getParams],
+      queryFn: ({ queryKey: [_u, _l, page, getParams] }: QueryFunctionContext<QueryKey>) =>
+        getUsers<U>({ ...getParams, pageIndex: page }),
+      staleTime: STALE_TIME,
+    })),
+  });
+
+  return results.reduce(
+    (acc, { data, isLoading }) => ({
+      users: acc.users.concat(data?.users ?? []),
+      total: data?.pageRestResponse.total,
+      isLoading: acc.isLoading || isLoading,
+    }),
+    { users: [] as U[], total: 0, isLoading: false }
+  );
+}
+
+export function useInvalidateUsersList() {
+  const queryClient = useQueryClient();
+
+  return () => queryClient.invalidateQueries({ queryKey: ['user', 'list'] });
+}
+
+export function useCreateUserMutation() {
+  const queryClient = useQueryClient();
+
+  return useMutation({
+    mutationFn: async (data: Parameters<typeof createUser>[0]) => {
+      await createUser(data);
+    },
+    onSuccess() {
+      queryClient.invalidateQueries({ queryKey: ['user', 'list'] });
+    },
+  });
+}
+
+export function useUpdateUserMutation() {
+  const queryClient = useQueryClient();
+
+  return useMutation({
+    mutationFn: async (data: Parameters<typeof updateUser>[0]) => {
+      await updateUser(data);
+    },
+    onSuccess() {
+      queryClient.invalidateQueries({ queryKey: ['user', 'list'] });
+    },
+  });
+}
+
+export function useDeactivateUserMutation() {
+  const queryClient = useQueryClient();
+
+  return useMutation({
+    mutationFn: async (data: Parameters<typeof deleteUser>[0]) => {
+      await deleteUser(data);
+    },
+    onSuccess() {
+      queryClient.invalidateQueries({ queryKey: ['user', 'list'] });
+    },
+  });
+}
index c40de0fde6cd1d1b67b00084a91cba1be1569c34..ab2421f700ec74f39c4f663f564229e79c6cdc69 100644 (file)
@@ -91,6 +91,30 @@ export interface UserBase {
   name?: string;
 }
 
+export interface RestUserBase {
+  id: string;
+  login: string;
+  name: string;
+}
+
+export interface RestUser extends RestUserBase {
+  email: string;
+  active: boolean;
+  local: boolean;
+  externalProvider: string;
+  avatar: string;
+}
+
+export interface RestUserDetailed extends RestUser {
+  managed: boolean;
+  externalLogin: string;
+  sonarQubeLastConnectionDate: string | null;
+  sonarLintLastConnectionDate: string | null;
+  scmAccounts: string[];
+  groupsCount: number;
+  tokensCount: number;
+}
+
 export const enum ChangePasswordResults {
   OldPasswordIncorrect = 'old_password_incorrect',
   NewPasswordSameAsOld = 'new_password_same_as_old',