From: guillaume-peoch-sonarsource Date: Wed, 26 Jul 2023 14:49:26 +0000 (+0200) Subject: SONAR-19967 Use DELETE api/v2/users/:login on the FE X-Git-Tag: 10.2.0.77647~271 X-Git-Url: https://source.dussan.org/?a=commitdiff_plain;h=3ad3b0ad0d24d9b95314398e455b18fb5c719479;p=sonarqube.git SONAR-19967 Use DELETE api/v2/users/:login on the FE --- 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 71f4e226847..decdb9c4d53 100644 --- a/server/sonar-web/src/main/js/api/mocks/UsersServiceMock.ts +++ b/server/sonar-web/src/main/js/api/mocks/UsersServiceMock.ts @@ -21,17 +21,15 @@ 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 = (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 index bc18ed5cbc9..00000000000 --- a/server/sonar-web/src/main/js/api/queries/users.ts +++ /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

( - getParams: Omit, - numberOfPages: number -) { - type QueryKey = ['user', 'list', number, Omit]; - const results = useQueries({ - queries: range(1, numberOfPages + 1).map((page: number) => ({ - queryKey: ['user', 'list', page, getParams], - queryFn: ({ queryKey: [_u, _l, page, getParams] }: QueryFunctionContext) => - getUsers

({ ...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

[], 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'] }); - }, - }); -} diff --git a/server/sonar-web/src/main/js/api/users.ts b/server/sonar-web/src/main/js/api/users.ts index 72c569b8dda..d75c811ff95 100644 --- a/server/sonar-web/src/main/js/api/users.ts +++ b/server/sonar-web/src/main/js/api/users.ts @@ -18,9 +18,17 @@ * 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 { 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(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 '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( - data: GetUsersParams -): Promise<{ pageRestResponse: Paging; users: RestUser[] }> { +}): 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 { +}): Promise { 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 { diff --git a/server/sonar-web/src/main/js/apps/users/UsersApp.tsx b/server/sonar-web/src/main/js/apps/users/UsersApp.tsx index e087a7dd8e2..7ba7969debb 100644 --- a/server/sonar-web/src/main/js/apps/users/UsersApp.tsx +++ b/server/sonar-web/src/main/js/apps/users/UsersApp.tsx @@ -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( { q: search, managed, diff --git a/server/sonar-web/src/main/js/apps/users/UsersList.tsx b/server/sonar-web/src/main/js/apps/users/UsersList.tsx index c3d01379d49..33ce5102cd8 100644 --- a/server/sonar-web/src/main/js/apps/users/UsersList.tsx +++ b/server/sonar-web/src/main/js/apps/users/UsersList.tsx @@ -18,24 +18,19 @@ * 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 (

@@ -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} 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 dc7b33c2f85..73e454c64a3 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 @@ -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/); }); diff --git a/server/sonar-web/src/main/js/apps/users/components/DeactivateForm.tsx b/server/sonar-web/src/main/js/apps/users/components/DeactivateForm.tsx index aa334af4bb4..b7fbf2077bf 100644 --- a/server/sonar-web/src/main/js/apps/users/components/DeactivateForm.tsx +++ b/server/sonar-web/src/main/js/apps/users/components/DeactivateForm.tsx @@ -19,13 +19,13 @@ */ 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 { 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 fda16770551..e27d00ec0d3 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,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) { diff --git a/server/sonar-web/src/main/js/apps/users/components/PasswordForm.tsx b/server/sonar-web/src/main/js/apps/users/components/PasswordForm.tsx index 0387363c3c6..6e9a17d3237 100644 --- a/server/sonar-web/src/main/js/apps/users/components/PasswordForm.tsx +++ b/server/sonar-web/src/main/js/apps/users/components/PasswordForm.tsx @@ -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 { - 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( + 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) => - this.setState({ confirmPassword: event.currentTarget.value }); - - handleNewPasswordChange = (event: React.SyntheticEvent) => - this.setState({ newPassword: event.currentTarget.value }); - - handleOldPasswordChange = (event: React.SyntheticEvent) => - this.setState({ oldPassword: event.currentTarget.value }); - - handleChangePassword = (event: React.SyntheticEvent) => { + const handleChangePassword = (event: React.SyntheticEvent) => { 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 ( - -
-
-

{header}

-
-
- {errorTranslationKey && {translate(errorTranslationKey)}} + return ( + + +
+

{header}

+
+
+ {errorTranslationKey && {translate(errorTranslationKey)}} - + - {this.props.isCurrentUser && ( -
- - {/* keep this fake field to hack browser autofill */} - - -
- )} + {isCurrentUser && (
-
-
- - {/* keep this fake field to hack browser autofill */} - -
+ )} +
+ + {/* keep this fake field to hack browser autofill */} + + setNewPassword(event.currentTarget.value)} + required + type="password" + value={newPassword} + /> +
+
+ + {/* keep this fake field to hack browser autofill */} + + setConfirmPassword(event.currentTarget.value)} + required + type="password" + value={confirmPassword} + />
-
- {submitting && } - - {translate('change_verb')} - - {translate('cancel')} -
- - - ); - } +
+
+ {submitting && } + + {translate('change_verb')} + + {translate('cancel')} +
+ +
+ ); } 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 87c219710f7..0524be649a7 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 @@ -19,15 +19,15 @@ */ 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; } diff --git a/server/sonar-web/src/main/js/apps/users/components/UserActions.tsx b/server/sonar-web/src/main/js/apps/users/components/UserActions.tsx index aa61b550194..8d9937de08b 100644 --- a/server/sonar-web/src/main/js/apps/users/components/UserActions.tsx +++ b/server/sonar-web/src/main/js/apps/users/components/UserActions.tsx @@ -18,25 +18,23 @@ * 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(undefined); @@ -90,11 +88,7 @@ export default function UserActions(props: Props) { setOpenForm(undefined)} user={user} /> )} {openForm === 'password' && ( - setOpenForm(undefined)} - user={user} - /> + setOpenForm(undefined)} user={user} /> )} {openForm === 'update' && setOpenForm(undefined)} user={user} />} diff --git a/server/sonar-web/src/main/js/apps/users/components/UserForm.tsx b/server/sonar-web/src/main/js/apps/users/components/UserForm.tsx index fc18984a583..b63ad7288ee 100644 --- a/server/sonar-web/src/main/js/apps/users/components/UserForm.tsx +++ b/server/sonar-web/src/main/js/apps/users/components/UserForm.tsx @@ -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(user?.email || ''); - const [login, setLogin] = React.useState(user?.login || ''); - const [name, setName] = React.useState(user?.name || ''); + const [email, setEmail] = React.useState(user?.email ?? ''); + const [login, setLogin] = React.useState(user?.login ?? ''); + const [name, setName] = React.useState(user?.name ?? ''); const [password, setPassword] = React.useState(''); - const [scmAccounts, setScmAccounts] = React.useState(user?.scmAccounts || []); + const [scmAccounts, setScmAccounts] = React.useState(user?.scmAccounts ?? []); const [error, setError] = React.useState(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 } 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 e5c6a7f4c88..e0f314eb742 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 @@ -18,13 +18,13 @@ * 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) && (
)} diff --git a/server/sonar-web/src/main/js/apps/users/components/UserListItemIdentity.tsx b/server/sonar-web/src/main/js/apps/users/components/UserListItemIdentity.tsx index e21060242eb..c432e59b623 100644 --- a/server/sonar-web/src/main/js/apps/users/components/UserListItemIdentity.tsx +++ b/server/sonar-web/src/main/js/apps/users/components/UserListItemIdentity.tsx @@ -20,15 +20,15 @@ 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; } diff --git a/server/sonar-web/src/main/js/helpers/request.ts b/server/sonar-web/src/main/js/helpers/request.ts index ff58851874f..9b40c1f14ed 100644 --- a/server/sonar-web/src/main/js/helpers/request.ts +++ b/server/sonar-web/src/main/js/helpers/request.ts @@ -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 { + return request(url) + .setMethod('DELETE') + .setData(data) + .submit() + .then((response) => checkStatus(response)); +} + function tryRequestAgain( repeatAPICall: () => Promise, tries: { max: number; slowThreshold: number }, diff --git a/server/sonar-web/src/main/js/helpers/testMocks.ts b/server/sonar-web/src/main/js/helpers/testMocks.ts index c3ee78cb3a4..474e43af881 100644 --- a/server/sonar-web/src/main/js/helpers/testMocks.ts +++ b/server/sonar-web/src/main/js/helpers/testMocks.ts @@ -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 { return { @@ -681,7 +680,7 @@ export function mockUser(overrides: Partial = {}): User { }; } -export function mockRestUser(overrides: Partial> = {}): RestUser<'admin'> { +export function mockRestUser(overrides: Partial = {}): 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 index 00000000000..51e3ce0acb9 --- /dev/null +++ b/server/sonar-web/src/main/js/queries/users.ts @@ -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( + getParams: Omit[0], 'pageSize' | 'pageIndex'>, + numberOfPages: number +) { + type QueryKey = [ + 'user', + 'list', + number, + Omit[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) => + getUsers({ ...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[0]) => { + await createUser(data); + }, + onSuccess() { + queryClient.invalidateQueries({ queryKey: ['user', 'list'] }); + }, + }); +} + +export function useUpdateUserMutation() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (data: Parameters[0]) => { + await updateUser(data); + }, + onSuccess() { + queryClient.invalidateQueries({ queryKey: ['user', 'list'] }); + }, + }); +} + +export function useDeactivateUserMutation() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (data: Parameters[0]) => { + await deleteUser(data); + }, + onSuccess() { + queryClient.invalidateQueries({ queryKey: ['user', 'list'] }); + }, + }); +} diff --git a/server/sonar-web/src/main/js/types/users.ts b/server/sonar-web/src/main/js/types/users.ts index c40de0fde6c..ab2421f700e 100644 --- a/server/sonar-web/src/main/js/types/users.ts +++ b/server/sonar-web/src/main/js/types/users.ts @@ -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',
- +