diff --git a/packages/manager/apps/web-office/public/translations/dashboard/Messages_fr_FR.json b/packages/manager/apps/web-office/public/translations/dashboard/Messages_fr_FR.json index 30a3f4ffd29d..e26b4931d9ff 100644 --- a/packages/manager/apps/web-office/public/translations/dashboard/Messages_fr_FR.json +++ b/packages/manager/apps/web-office/public/translations/dashboard/Messages_fr_FR.json @@ -4,5 +4,7 @@ "microsoft_office_dashboard_consumption": "Consommation", "microsoft_office_dashboard_licences": "Licences", "microsoft_office_dashboard_guides": "Consulter nos guides en ligne", + "microsoft_office_dashboard_users": "Utilisateurs", + "microsoft_office_dashboard_users_delete": "Suppression d'un utilisateur", "back_link": "Retour à la liste" } diff --git a/packages/manager/apps/web-office/public/translations/dashboard/users/delete/Messages_fr_FR.json b/packages/manager/apps/web-office/public/translations/dashboard/users/delete/Messages_fr_FR.json new file mode 100644 index 000000000000..480807f4963c --- /dev/null +++ b/packages/manager/apps/web-office/public/translations/dashboard/users/delete/Messages_fr_FR.json @@ -0,0 +1,10 @@ +{ + "dashboard_users_delete_title": "Suppression d'un utilisateur", + "dashboard_users_delete_confirm": "Êtes-vous sûr de vouloir supprimer l'utilisateur {{t0}} ?", + "dashboard_users_delete_info1": "L'utilisateur sera supprimé immédiatement après confirmation.", + "dashboard_users_delete_info2": "La facturation de nos produits Office 365 étant basée sur la consommation du mois précédent, vous recevrez une dernière facture pour ce compte.", + "dashboard_users_delete_cta_cancel": "Annuler", + "dashboard_users_delete_cta_confirm": "Valider", + "dashboard_users_delete_message_error": "Une erreur est survenue lors de la suppression de l'utilisateur. {{ error }}", + "dashboard_users_delete_message_success": "Utilisateur en cours de suppression." +} diff --git a/packages/manager/apps/web-office/src/api/_mock_/license.ts b/packages/manager/apps/web-office/src/api/_mock_/license.ts index 93e2a8d7f5cb..3e6933437359 100644 --- a/packages/manager/apps/web-office/src/api/_mock_/license.ts +++ b/packages/manager/apps/web-office/src/api/_mock_/license.ts @@ -1,5 +1,6 @@ -import { LicenseEnum, UserStateEnum } from '../api.type'; +import { LicenseEnum, TaskStatusEnum, UserStateEnum } from '../api.type'; import { LicensePrepaidType, LicenseType } from '../license/type'; +import { PendingTaskType } from '../users'; export const licensesMock: LicenseType[] = [ { @@ -79,3 +80,11 @@ export const licensesPrepaidExpandedMock: LicensePrepaidType[] = [ }, }, ]; + +export const tenantPendingTask: PendingTaskType = { + finishDate: '2025-01-09T12:00:12+01:00', + function: 'unconfigureOffice365UserNCE', + id: 581562, + status: TaskStatusEnum.DONE, + todoDate: '2025-01-09T12:00:12+01:00', +}; diff --git a/packages/manager/apps/web-office/src/api/_mock_/user.ts b/packages/manager/apps/web-office/src/api/_mock_/user.ts index b09c26576bc7..7ad0b5334225 100644 --- a/packages/manager/apps/web-office/src/api/_mock_/user.ts +++ b/packages/manager/apps/web-office/src/api/_mock_/user.ts @@ -1,5 +1,5 @@ -import { LicenseEnum, UserStateEnum } from '../api.type'; -import { UserNativeType } from '../users/type'; +import { LicenseEnum, TaskStatusEnum, UserStateEnum } from '../api.type'; +import { PendingTaskType, UserNativeType } from '../users/type'; export const usersMock: UserNativeType[] = [ { @@ -25,3 +25,11 @@ export const usersMock: UserNativeType[] = [ usageLocation: 'fr', }, ]; + +export const pendingTask: PendingTaskType = { + finishDate: null, + function: 'deleteOffice365UserPaygNCE', + id: 581553, + status: TaskStatusEnum.TODO, + todoDate: '2025-01-09T11:20:52+01:00', +}; diff --git a/packages/manager/apps/web-office/src/api/api.type.ts b/packages/manager/apps/web-office/src/api/api.type.ts index a50c6422e5c8..9700ef780daf 100644 --- a/packages/manager/apps/web-office/src/api/api.type.ts +++ b/packages/manager/apps/web-office/src/api/api.type.ts @@ -12,3 +12,11 @@ export enum LicenseEnum { OFFICE_BUSINESS = 'officeBusiness', OFFICE_PRO_PLUS = 'officeProPlus', } + +export enum TaskStatusEnum { + CANCELLED = 'cancelled', + DOING = 'doing', + DONE = 'done', + ERROR = 'error', + TODO = 'todo', +} diff --git a/packages/manager/apps/web-office/src/api/license/api.ts b/packages/manager/apps/web-office/src/api/license/api.ts index 819e3508a7e4..e82d67cecbfb 100644 --- a/packages/manager/apps/web-office/src/api/license/api.ts +++ b/packages/manager/apps/web-office/src/api/license/api.ts @@ -39,6 +39,18 @@ export const getOfficePrepaidLicenses = async (serviceName: string) => { // POST +export const postOfficePrepaidLicenseUnconfigure = async ( + serviceName: string, + activationEmail: string, +) => { + const { data } = await v6.post( + `${getApiPathWithoutServiceName( + serviceName, + )}${activationEmail}/unconfigure`, + ); + return data; +}; + // PUT // DELETE diff --git a/packages/manager/apps/web-office/src/api/users/api.ts b/packages/manager/apps/web-office/src/api/users/api.ts index a17d9c543e80..24727a7cb13f 100644 --- a/packages/manager/apps/web-office/src/api/users/api.ts +++ b/packages/manager/apps/web-office/src/api/users/api.ts @@ -1,4 +1,4 @@ -import { fetchIcebergV6 } from '@ovh-ux/manager-core-api'; +import { fetchIcebergV6, v6 } from '@ovh-ux/manager-core-api'; import { getApiPath } from '../utils/apiPath'; import { UserNativeType } from './type'; @@ -19,3 +19,13 @@ export const getOfficeUsers = async ( // PUT // DELETE + +export const deleteOfficeUser = async ( + serviceName: string, + activationEmail: string, +) => { + const { data } = await v6.delete( + `${getApiPath(serviceName)}user/${activationEmail}`, + ); + return data; +}; diff --git a/packages/manager/apps/web-office/src/api/users/type.ts b/packages/manager/apps/web-office/src/api/users/type.ts index f454e9c34692..b9502ee58703 100644 --- a/packages/manager/apps/web-office/src/api/users/type.ts +++ b/packages/manager/apps/web-office/src/api/users/type.ts @@ -1,4 +1,4 @@ -import { LicenseEnum, UserStateEnum } from '../api.type'; +import { LicenseEnum, TaskStatusEnum, UserStateEnum } from '../api.type'; export type UserNativeType = { activationEmail: string; @@ -17,3 +17,11 @@ export type UserNativeType = { | LicenseEnum[] | { id: string; urn: string }; }; + +export type PendingTaskType = { + finishDate?: string; + function: string; + id: number; + status: TaskStatusEnum; + todoDate: string; +}; diff --git a/packages/manager/apps/web-office/src/components/Modals/Modal.tsx b/packages/manager/apps/web-office/src/components/Modals/Modal.tsx new file mode 100644 index 000000000000..95b691abfd93 --- /dev/null +++ b/packages/manager/apps/web-office/src/components/Modals/Modal.tsx @@ -0,0 +1,100 @@ +import React from 'react'; +import { OdsButton, OdsModal, OdsText } from '@ovhcloud/ods-components/react'; +import { + ODS_BUTTON_VARIANT, + ODS_MODAL_COLOR, + ODS_BUTTON_COLOR, + ODS_TEXT_PRESET, +} from '@ovhcloud/ods-components'; +import Loading from '@/components/Loading/Loading'; + +export interface ButtonType { + testid?: string; + action: () => void; + label: string; + isDisabled?: boolean; + isLoading?: boolean; + variant?: ODS_BUTTON_VARIANT; +} + +export interface ModalProps { + title?: string; + color?: ODS_MODAL_COLOR; + isDismissible?: boolean; + isLoading?: boolean; + isOpen?: boolean; + onClose?: () => void; + children?: React.ReactElement; + primaryButton?: ButtonType; + secondaryButton?: ButtonType; +} + +const mapModalColorToButtonColor = (modalColor: ODS_MODAL_COLOR) => { + if (modalColor === ODS_MODAL_COLOR.critical) { + return ODS_BUTTON_COLOR.critical; + } + return ODS_BUTTON_COLOR.primary; +}; + +const Modal: React.FC = ({ + color = ODS_MODAL_COLOR.information, + isDismissible, + onClose, + isLoading, + primaryButton, + secondaryButton, + children, + isOpen, + title, +}) => { + const buttonColor = mapModalColorToButtonColor(color); + + return ( + + + {title} + + {!isLoading &&
{children}
} + {isLoading && } + {secondaryButton && ( + + )} + {primaryButton && ( + + )} +
+ ); +}; + +export default Modal; diff --git a/packages/manager/apps/web-office/src/hooks/useGenerateUrl.ts b/packages/manager/apps/web-office/src/hooks/useGenerateUrl.ts index eb36dba84aab..86f26b26bf6a 100644 --- a/packages/manager/apps/web-office/src/hooks/useGenerateUrl.ts +++ b/packages/manager/apps/web-office/src/hooks/useGenerateUrl.ts @@ -1,13 +1,10 @@ import { useHref } from 'react-router-dom'; -import { useOfficeLicenseDetail } from '@/hooks'; -export const UseGenerateUrl = ( +export const useGenerateUrl = ( baseURL: string, type: 'path' | 'href' = 'path', params?: Record, ) => { - const { data: serviceName } = useOfficeLicenseDetail(); - const URL = baseURL.replace( ':serviceName', (params?.serviceName as string) || '', @@ -15,7 +12,6 @@ export const UseGenerateUrl = ( const queryParams = { ...params, - ...(serviceName && { serviceNameDetail: serviceName }), }; const queryString = Object.entries(queryParams) diff --git a/packages/manager/apps/web-office/src/pages/dashboard/index.tsx b/packages/manager/apps/web-office/src/pages/dashboard/index.tsx index 065623a0234f..4adaad6a2cdb 100644 --- a/packages/manager/apps/web-office/src/pages/dashboard/index.tsx +++ b/packages/manager/apps/web-office/src/pages/dashboard/index.tsx @@ -3,6 +3,8 @@ import { useTranslation } from 'react-i18next'; import { Outlet, useParams, useResolvedPath } from 'react-router-dom'; import { BaseLayout, + Notifications, + useNotifications, GuideButton, GuideItem, } from '@ovh-ux/manager-react-components'; @@ -26,6 +28,7 @@ export type DashboardLayoutProps = { export default function DashboardPage() { const { serviceName } = useParams(); const { t } = useTranslation('dashboard'); + const { notifications } = useNotifications(); const basePath = useResolvedPath('').pathname; const context = useContext(ShellContext); const { ovhSubsidiary } = context.environment.getUser(); @@ -69,6 +72,10 @@ export default function DashboardPage() { breadcrumb={} header={header} tabs={} + message={ + // temporary fix margin even if empty + notifications.length ? : null + } > diff --git a/packages/manager/apps/web-office/src/pages/dashboard/users/ActionButtonUsers.component.tsx b/packages/manager/apps/web-office/src/pages/dashboard/users/ActionButtonUsers.component.tsx index 367c763f080d..a08acd078990 100644 --- a/packages/manager/apps/web-office/src/pages/dashboard/users/ActionButtonUsers.component.tsx +++ b/packages/manager/apps/web-office/src/pages/dashboard/users/ActionButtonUsers.component.tsx @@ -2,10 +2,12 @@ import React from 'react'; import { useTranslation } from 'react-i18next'; import { ActionMenu } from '@ovh-ux/manager-react-components'; import { ODS_BUTTON_COLOR, ODS_BUTTON_VARIANT } from '@ovhcloud/ods-components'; +import { useNavigate } from 'react-router-dom'; import { IAM_ACTIONS } from '@/utils/iamAction.constants'; import { UserStateEnum } from '@/api/api.type'; import { UserNativeType } from '@/api/users/type'; import { LicenseType } from '@/api/license/type'; +import { useGenerateUrl } from '@/hooks'; interface ActionButtonUsersProps { usersItem: UserNativeType; @@ -16,16 +18,21 @@ const ActionButtonUsers: React.FC = ({ licenceDetail, }) => { const { t } = useTranslation('dashboard/users'); + const navigate = useNavigate(); + + const hrefDeleteUsers = useGenerateUrl('./users/delete', 'path', { + activationEmail: usersItem.activationEmail, + ...(!licenceDetail.serviceType && { + licencePrepaidName: licenceDetail.serviceName, + }), + }); const handlePasswordChangeClick = () => { // @todo: for next user story console.log('handlePasswordChangeClick'); }; - const handleDeleteUserClick = () => { - // @todo: for next user story - console.log('handleDeleteUserClick'); - }; + const handleDeleteUserClick = () => navigate(hrefDeleteUsers); const handleEditUserClick = () => { // @todo: for next user story diff --git a/packages/manager/apps/web-office/src/pages/dashboard/users/ModalDeleteUsers.component.tsx b/packages/manager/apps/web-office/src/pages/dashboard/users/ModalDeleteUsers.component.tsx new file mode 100644 index 000000000000..c0fcbc2e5d26 --- /dev/null +++ b/packages/manager/apps/web-office/src/pages/dashboard/users/ModalDeleteUsers.component.tsx @@ -0,0 +1,108 @@ +import React from 'react'; +import { useSearchParams, useNavigate, useParams } from 'react-router-dom'; +import { Trans, useTranslation } from 'react-i18next'; +import { OdsText } from '@ovhcloud/ods-components/react'; +import { useNotifications } from '@ovh-ux/manager-react-components'; +import { ApiError } from '@ovh-ux/manager-core-api'; +import { useMutation } from '@tanstack/react-query'; +import { + ODS_BUTTON_VARIANT, + ODS_MODAL_COLOR, + ODS_TEXT_PRESET, +} from '@ovhcloud/ods-components'; +import { useGenerateUrl } from '@/hooks'; +import Modal from '@/components/Modals/Modal'; +import { + getOfficeLicenseQueryKey, + postOfficePrepaidLicenseUnconfigure, +} from '@/api/license'; +import { deleteOfficeUser, getOfficeUsersQueryKey } from '@/api/users'; +import queryClient from '@/queryClient'; + +export default function ModalDeleteUsers() { + const { t } = useTranslation('dashboard/users/delete'); + const navigate = useNavigate(); + + const { serviceName: selectedServiceName } = useParams(); + const [searchParams] = useSearchParams(); + const activationEmail = searchParams.get('activationEmail'); + const licencePrepaidName = searchParams.get('licencePrepaidName'); + + const { addError, addSuccess } = useNotifications(); + + const goBackUrl = useGenerateUrl('..', 'path'); + const onClose = () => navigate(goBackUrl); + + const { mutate: deleteUsers, isPending: isDeleting } = useMutation({ + mutationFn: () => + licencePrepaidName + ? postOfficePrepaidLicenseUnconfigure( + selectedServiceName, + licencePrepaidName, + ) + : deleteOfficeUser(selectedServiceName, activationEmail), + onSuccess: () => { + addSuccess( + + {t('dashboard_users_delete_message_success')} + , + true, + ); + }, + onError: (error: ApiError) => { + addError( + + {t('dashboard_users_delete_message_error', { + error: error?.response?.data?.message, + })} + , + true, + ); + }, + onSettled: () => { + queryClient.invalidateQueries({ + queryKey: licencePrepaidName + ? getOfficeLicenseQueryKey(selectedServiceName) + : getOfficeUsersQueryKey(selectedServiceName), + }); + onClose(); + }, + }); + + return ( + + +

+ +

+
    +
  • {t('dashboard_users_delete_info1')}
  • +
  • {t('dashboard_users_delete_info2')}
  • +
+
+
+ ); +} diff --git a/packages/manager/apps/web-office/src/pages/dashboard/users/Users.tsx b/packages/manager/apps/web-office/src/pages/dashboard/users/Users.tsx index 386c837a5a49..291ccc312fc9 100644 --- a/packages/manager/apps/web-office/src/pages/dashboard/users/Users.tsx +++ b/packages/manager/apps/web-office/src/pages/dashboard/users/Users.tsx @@ -1,4 +1,5 @@ import React from 'react'; +import { Outlet } from 'react-router-dom'; import { Trans, useTranslation } from 'react-i18next'; import { Datagrid, DatagridColumn } from '@ovh-ux/manager-react-components'; import { @@ -28,10 +29,6 @@ export default function Users() { isLoading: isLoadingLicenceDetail, } = useOfficeLicenseDetail(); - if (isLoadingUsers || isLoadingLicenceDetail) { - return ; - } - const columns: DatagridColumn[] = [ { id: 'firstName', @@ -92,7 +89,8 @@ export default function Users() { ]; return ( -
+
+ {t('dashboard_users_download_info')}

{t('dashboard_users_download_id')}
- - {columns && ( - ({ - ...column, - label: t(column.label), - }))} - items={dataUsers || []} - totalItems={dataUsers?.length || 0} - className="mt-4" - /> + {isLoadingUsers || isLoadingLicenceDetail ? ( + + ) : ( + <> + + ({ + ...column, + label: t(column.label), + }))} + items={dataUsers || []} + totalItems={dataUsers?.length || 0} + className="mt-4" + /> + )}
); diff --git a/packages/manager/apps/web-office/src/pages/dashboard/users/__test__/ModalDeleteUsers.component.spec.tsx b/packages/manager/apps/web-office/src/pages/dashboard/users/__test__/ModalDeleteUsers.component.spec.tsx new file mode 100644 index 000000000000..0730a17ca3dc --- /dev/null +++ b/packages/manager/apps/web-office/src/pages/dashboard/users/__test__/ModalDeleteUsers.component.spec.tsx @@ -0,0 +1,50 @@ +import React from 'react'; +import { describe, expect, it, vi } from 'vitest'; +import { useSearchParams } from 'react-router-dom'; +import ModalDeleteUsers from '../ModalDeleteUsers.component'; +import { fireEvent, render, act } from '@/utils/test.provider'; +import { postOfficePrepaidLicenseUnconfigure } from '@/api/license'; +import { deleteOfficeUser } from '@/api/users'; + +describe('ModalDeleteUsers Component', () => { + it('if prepaid licence with licencePrepaidName', async () => { + vi.mocked(useSearchParams).mockReturnValue([ + new URLSearchParams({ + activationEmail: 'activationEmail@activationEmail', + licencePrepaidName: 'licencePrepaidName', + }), + vi.fn(), + ]); + + const { getByTestId } = render(); + + const deleteButton = getByTestId('delete-btn'); + + await act(() => { + fireEvent.click(deleteButton); + }); + + expect(postOfficePrepaidLicenseUnconfigure).toHaveBeenCalledOnce(); + }); + + it('if postpaid licence without licencePrepaidName', async () => { + vi.mocked(useSearchParams).mockReturnValue([ + new URLSearchParams({ + activationEmail: 'activationEmail@activationEmail', + }), + vi.fn(), + ]); + + const { getByTestId } = render(); + + const deleteButton = getByTestId('delete-btn'); + + expect(deleteButton).toBeInTheDocument(); + + await act(() => { + fireEvent.click(deleteButton); + }); + + expect(deleteOfficeUser).toHaveBeenCalledOnce(); + }); +}); diff --git a/packages/manager/apps/web-office/src/pages/licenses/licenses.page.tsx b/packages/manager/apps/web-office/src/pages/licenses/licenses.page.tsx index b2457a05652b..e92280028e96 100644 --- a/packages/manager/apps/web-office/src/pages/licenses/licenses.page.tsx +++ b/packages/manager/apps/web-office/src/pages/licenses/licenses.page.tsx @@ -19,14 +19,14 @@ import { ShellContext } from '@ovh-ux/manager-react-shell-client'; import { CTAS } from '@/guides.constants'; import { urls } from '@/routes/routes.constants'; import { LicenseType } from '@/api/license'; -import { useOfficeLicenses, UseGenerateUrl } from '@/hooks'; +import { useOfficeLicenses, useGenerateUrl } from '@/hooks'; import Loading from '@/components/Loading/Loading'; const columns: DatagridColumn[] = [ { id: 'serviceName', cell: (item) => { - const href = UseGenerateUrl(urls.license, 'href', { + const href = useGenerateUrl(urls.license, 'href', { serviceName: item.serviceName, }); diff --git a/packages/manager/apps/web-office/src/queryClient.ts b/packages/manager/apps/web-office/src/queryClient.ts new file mode 100644 index 000000000000..94100b5328ad --- /dev/null +++ b/packages/manager/apps/web-office/src/queryClient.ts @@ -0,0 +1,11 @@ +import { QueryClient } from '@tanstack/react-query'; + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + staleTime: 300_000, + }, + }, +}); + +export default queryClient; diff --git a/packages/manager/apps/web-office/src/routes/routes.tsx b/packages/manager/apps/web-office/src/routes/routes.tsx index 82cd177585b2..22943ba52b34 100644 --- a/packages/manager/apps/web-office/src/routes/routes.tsx +++ b/packages/manager/apps/web-office/src/routes/routes.tsx @@ -44,6 +44,20 @@ export const Routes: any = [ pageType: PageType.dashboard, }, }, + children: [ + { + path: 'users/delete', + ...lazyRouteConfig(() => + import('@/pages/dashboard/users/ModalDeleteUsers.component'), + ), + handle: { + tracking: { + pageName: 'users-delete', + pageType: PageType.popup, + }, + }, + }, + ], }, { path: 'consumption', diff --git a/packages/manager/apps/web-office/src/utils/test.setup.tsx b/packages/manager/apps/web-office/src/utils/test.setup.tsx index 21c7021685a7..ad44c9f34d30 100644 --- a/packages/manager/apps/web-office/src/utils/test.setup.tsx +++ b/packages/manager/apps/web-office/src/utils/test.setup.tsx @@ -4,6 +4,8 @@ import { licensesMock, licensesPrepaidMock, licensesPrepaidExpandedMock, + pendingTask, + tenantPendingTask, } from '@/api/_mock_'; const mocksAxios = vi.hoisted(() => ({ @@ -67,6 +69,9 @@ vi.mock('@/api/license', async (importActual) => { ), ); }), + postOfficePrepaidLicenseUnconfigure: vi.fn(() => { + return Promise.resolve(pendingTask); + }), }; }); @@ -76,6 +81,9 @@ vi.mock('@/api/users', async (importActual) => { getOfficeUsers: vi.fn(() => { return Promise.resolve(usersMock); }), + deleteOfficeUser: vi.fn(() => { + return Promise.resolve(tenantPendingTask); + }), }; });