diff --git a/package-lock.json b/package-lock.json index c70619b..0170559 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "ucentral-client", - "version": "2.9.0(2)", + "version": "2.9.0(3)", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "ucentral-client", - "version": "2.9.0(2)", + "version": "2.9.0(3)", "license": "ISC", "dependencies": { "@chakra-ui/icons": "^2.0.11", diff --git a/package.json b/package.json index 1f95789..361cd11 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ucentral-client", - "version": "2.9.0(2)", + "version": "2.9.0(3)", "description": "", "private": true, "main": "index.tsx", diff --git a/public/locales/de/translation.json b/public/locales/de/translation.json index ea95f16..c43e5a7 100644 --- a/public/locales/de/translation.json +++ b/public/locales/de/translation.json @@ -801,6 +801,7 @@ "reset_password": "Passwort zurücksetzen", "sign_in": "Einloggen", "sms_instructions": "Sie sollten bald einen 6-stelligen Code auf Ihrem Telefon erhalten. Bitte geben Sie es unten ein, um sich anzumelden", + "suspended_error": "Konto gesperrt, wenden Sie sich bitte an Ihren Administrator", "verification": "Bestätigen Sie Ihre Anmeldung", "waiting_for_email_verification": "Konto noch nicht per E-Mail validiert. Bitte sehen Sie in Ihrem Posteingang nach oder bitten Sie Ihren Administrator, eine Bestätigung erneut zu senden", "welcome_back": "Willkommen zurück!", @@ -1057,9 +1058,11 @@ "previous_page": "Vorherige Seite" }, "user": { + "email_not_validated": "E-Mail nicht validiert", "error_fetching": "Fehler beim Abrufen der Benutzerinformationen: {{e}}", "password": "Passwort", "role": "Rolle", + "suspended": "Suspendiert", "title": "Nutzer" }, "users": { diff --git a/public/locales/en/translation.json b/public/locales/en/translation.json index bfede2d..9a0eacf 100644 --- a/public/locales/en/translation.json +++ b/public/locales/en/translation.json @@ -801,6 +801,7 @@ "reset_password": "Reset Password", "sign_in": "Sign In", "sms_instructions": "You should receive a 6-digit code on your phone soon. Please enter it below to login", + "suspended_error": "Suspended account, please contact your administrator", "verification": "Verify your login", "waiting_for_email_verification": "Account not yet email validated. Please look at your inbox or ask your administrator to resend a validation", "welcome_back": "Welcome Back!", @@ -1057,9 +1058,11 @@ "previous_page": "Previous Page" }, "user": { + "email_not_validated": "email not validated", "error_fetching": "Error fetching user information: {{e}}", "password": "Password", "role": "Role", + "suspended": "suspended", "title": "User" }, "users": { diff --git a/public/locales/es/translation.json b/public/locales/es/translation.json index 57573a0..1b9eb11 100644 --- a/public/locales/es/translation.json +++ b/public/locales/es/translation.json @@ -801,6 +801,7 @@ "reset_password": "Restablecer la contraseña", "sign_in": "Registrarse", "sms_instructions": "Debería recibir un código de 6 dígitos en su teléfono pronto. Por favor, introdúzcalo a continuación para iniciar sesión", + "suspended_error": "Cuenta suspendida, comuníquese con su administrador", "verification": "Verifica tu inicio de sesión", "waiting_for_email_verification": "Cuenta aún no validada por correo electrónico. Mire su bandeja de entrada o solicite a su administrador que vuelva a enviar una validación.", "welcome_back": "¡Dar una buena acogida!", @@ -1057,9 +1058,11 @@ "previous_page": "Página anterior" }, "user": { + "email_not_validated": "correo electrónico no validado", "error_fetching": "Error al obtener la información del usuario: {{e}}", "password": "Contraseña", "role": "papel", + "suspended": "Suspendido", "title": "Usuario" }, "users": { diff --git a/public/locales/fr/translation.json b/public/locales/fr/translation.json index a9f54a7..2a2c5ad 100644 --- a/public/locales/fr/translation.json +++ b/public/locales/fr/translation.json @@ -801,6 +801,7 @@ "reset_password": "Réinitialiser le mot de passe", "sign_in": "se connecter", "sms_instructions": "Vous devriez bientôt recevoir un code à 6 chiffres sur votre téléphone. Veuillez le saisir ci-dessous pour vous connecter", + "suspended_error": "Compte suspendu, veuillez contacter votre administrateur", "verification": "Vérifiez votre connexion", "waiting_for_email_verification": "Compte pas encore e-mail validé. Veuillez consulter votre boîte de réception ou demander à votre administrateur de renvoyer une validation", "welcome_back": "Nous saluons le retour!", @@ -1057,9 +1058,11 @@ "previous_page": "Page précédente" }, "user": { + "email_not_validated": "Mail non valide", "error_fetching": "Erreur lors de la récupération des informations utilisateur : {{e}}", "password": "Mot de passe", "role": "Rôle", + "suspended": "Suspendu", "title": "Utilisateur" }, "users": { diff --git a/public/locales/pt/translation.json b/public/locales/pt/translation.json index a057d1f..751297b 100644 --- a/public/locales/pt/translation.json +++ b/public/locales/pt/translation.json @@ -801,6 +801,7 @@ "reset_password": "Redefinir senha", "sign_in": "assinar em", "sms_instructions": "Você deve receber um código de 6 dígitos em seu telefone em breve. Por favor, insira-o abaixo para fazer login", + "suspended_error": "Conta suspensa, entre em contato com seu administrador", "verification": "Verifique seu login", "waiting_for_email_verification": "Conta ainda não validada por e-mail. Verifique sua caixa de entrada ou peça ao administrador para reenviar uma validação", "welcome_back": "Bem vindo de volta!", @@ -1057,9 +1058,11 @@ "previous_page": "Página anterior" }, "user": { + "email_not_validated": "e-mail não validado", "error_fetching": "Erro ao buscar informações do usuário: {{e}}", "password": "Senha", "role": "Função", + "suspended": "Suspenso", "title": "Do utilizador" }, "users": { diff --git a/src/components/Modals/GenericModal/ModalHeader/index.tsx b/src/components/Modals/GenericModal/ModalHeader/index.tsx index a956e84..269abdd 100644 --- a/src/components/Modals/GenericModal/ModalHeader/index.tsx +++ b/src/components/Modals/GenericModal/ModalHeader/index.tsx @@ -1,15 +1,19 @@ import React from 'react'; -import { Flex, ModalHeader as Header, Spacer } from '@chakra-ui/react'; +import { Flex, HStack, ModalHeader as Header, Spacer } from '@chakra-ui/react'; export interface ModalHeaderProps { title: string; + left?: React.ReactNode; right: React.ReactNode; } -const _ModalHeader: React.FC = ({ title, right }) => ( +const _ModalHeader: React.FC = ({ title, left, right }) => (
{title} + + {left ?? null} + {right} diff --git a/src/components/Modals/Modal/index.tsx b/src/components/Modals/Modal/index.tsx index bd75280..ab012d4 100644 --- a/src/components/Modals/Modal/index.tsx +++ b/src/components/Modals/Modal/index.tsx @@ -8,6 +8,7 @@ export type ModalProps = { onClose: () => void; title: string; topRightButtons?: React.ReactNode; + tags?: React.ReactNode; options?: { modalSize?: 'sm' | 'md' | 'lg'; maxWidth?: LayoutProps['maxWidth']; @@ -15,7 +16,7 @@ export type ModalProps = { children: React.ReactElement; }; -const _Modal = ({ isOpen, onClose, title, topRightButtons, options, children }: ModalProps) => { +const _Modal = ({ isOpen, onClose, title, topRightButtons, tags, options, children }: ModalProps) => { const maxWidth = React.useMemo(() => { if (options?.maxWidth) return options.maxWidth; if (options?.modalSize === 'sm') return undefined; @@ -32,6 +33,7 @@ const _Modal = ({ isOpen, onClose, title, topRightButtons, options, children }: {topRightButtons} diff --git a/src/hooks/Network/Firmware.ts b/src/hooks/Network/Firmware.ts index d5e88a1..2eab806 100644 --- a/src/hooks/Network/Firmware.ts +++ b/src/hooks/Network/Firmware.ts @@ -7,35 +7,49 @@ import { AxiosError } from 'models/Axios'; import { Firmware } from 'models/Firmware'; import { Note } from 'models/Note'; +const getAvailableFirmwareBatch = async (deviceType: string, limit: number, offset: number) => + axiosFms + .get(`firmwares?deviceType=${deviceType}&limit=${limit}&offset=${offset}`) + .then(({ data }: { data: { firmwares: Firmware[] } }) => data); + +const getAllAvailableFirmware = async (deviceType: string) => { + const limit = 500; + let offset = 0; + let data: { firmwares: Firmware[] } = { firmwares: [] }; + let lastResponse: { firmwares: Firmware[] } = { firmwares: [] }; + do { + // eslint-disable-next-line no-await-in-loop + lastResponse = await getAvailableFirmwareBatch(deviceType, limit, offset); + data = { + firmwares: [...data.firmwares, ...lastResponse.firmwares], + }; + offset += 500; + } while (lastResponse.firmwares.length === 500); + return data; +}; + export const useGetAvailableFirmware = ({ deviceType }: { deviceType: string }) => { const { t } = useTranslation(); const toast = useToast(); - return useQuery( - ['get-device-profile'], - () => - axiosFms - .get(`firmwares?deviceType=${deviceType}&limit=10000&offset=0`) - .then(({ data }: { data: { firmwares: Firmware[] } }) => data), - { - enabled: deviceType !== '', - onError: (e: AxiosError) => { - if (!toast.isActive('firmware-fetching-error')) - toast({ - id: 'firmware-fetching-error', - title: t('common.error'), - description: t('crud.error_fetching_obj', { - e: e?.response?.data?.ErrorDescription, - obj: t('analytics.firmware'), - }), - status: 'error', - duration: 5000, - isClosable: true, - position: 'top-right', - }); - }, + return useQuery(['firmware'], () => getAllAvailableFirmware(deviceType), { + enabled: deviceType !== '', + onError: (e: AxiosError) => { + if (!toast.isActive('firmware-fetching-error')) + toast({ + id: 'firmware-fetching-error', + title: t('common.error'), + description: t('crud.error_fetching_obj', { + e: e?.response?.data?.ErrorDescription, + obj: t('analytics.firmware'), + }), + status: 'error', + duration: 5000, + isClosable: true, + position: 'top-right', + }); }, - ); + }); }; export const useUpdateDeviceToLatest = ({ serialNumber, compatible }: { serialNumber: string; compatible: string }) => diff --git a/src/hooks/Network/Users.ts b/src/hooks/Network/Users.ts index 05918b2..af4e669 100644 --- a/src/hooks/Network/Users.ts +++ b/src/hooks/Network/Users.ts @@ -1,5 +1,5 @@ import { useToast } from '@chakra-ui/react'; -import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { QueryClient, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; import { axiosSec } from 'constants/axiosInstances'; import { AxiosError } from 'models/Axios'; @@ -58,12 +58,24 @@ export type User = { waitingForEmailCheck: boolean; }; -const getAvatarPromises = (userList: User[]) => { +const getAvatarPromises = (userList: User[], queryClient: QueryClient) => { const promises = userList.map(async (user) => { if (user.avatar !== '' && user.avatar !== '0') { - return axiosSec.get(`avatar/${user.id}?cache=${user.avatar}`, { - responseType: 'arraybuffer', - }); + // If the avatar is already in the cache, return it + const cachedAvatar = queryClient.getQueryData(['avatar', user.id, user.avatar]); + if (cachedAvatar) return cachedAvatar; + + return axiosSec + .get(`avatar/${user.id}?cache=${user.avatar}`, { + responseType: 'arraybuffer', + }) + .then((response) => { + queryClient.setQueryData(['avatar', user.id, user.avatar], response); + return response; + }) + .catch((e) => { + throw e; + }); } return Promise.resolve(''); }); @@ -71,10 +83,35 @@ const getAvatarPromises = (userList: User[]) => { return promises; }; -const getUsers = async () => { - const users = await axiosSec.get('users').then(({ data }) => data.users as User[]); +const getBatchUsers = async (offset: number, limit: number) => { + const users = await axiosSec + .get(`users?offset=${offset}&limit=${limit}&withExtendedInfo=true`) + .then(({ data }) => data.users as User[]); - const avatars = await Promise.allSettled(getAvatarPromises(users)).then((results) => + return users; +}; + +const getAllUsers = async () => { + let users: User[] = []; + let offset = 0; + const limit = 500; + let lastResponseLength = 0; + + do { + // eslint-disable-next-line no-await-in-loop + const response = await getBatchUsers(offset, limit); + users = [...users, ...response]; + offset += limit; + lastResponseLength = response.length; + } while (lastResponseLength === limit); + + return users; +}; + +const getUsers = async (queryClient: QueryClient) => { + const users = await getAllUsers(); + + const avatars = await Promise.allSettled(getAvatarPromises(users, queryClient)).then((results) => results.map((response) => { if (response.status === 'fulfilled' && response?.value !== '') { const base64 = btoa( @@ -93,8 +130,10 @@ const getUsers = async () => { export const useGetUsers = () => { const { t } = useTranslation(); const toast = useToast(); + const queryClient = useQueryClient(); - return useQuery(['users'], getUsers, { + return useQuery(['users'], () => getUsers(queryClient), { + staleTime: 30 * 1000, onError: (e: AxiosError) => { if (!toast.isActive('users-fetching-error')) toast({ @@ -118,7 +157,7 @@ export const useGetUser = ({ id, enabled }: { id: string; enabled: boolean }) => const toast = useToast(); return useQuery( - ['get-user', id], + ['users', id], () => axiosSec.get(`user/${id}?withExtendedInfo=true`).then(({ data }) => data as User), { enabled, @@ -173,16 +212,41 @@ export const useSendUserEmailValidation = ({ id, refresh }: { id: string; refres }, }); }; -export const useSuspendUser = ({ id }: { id: string }) => - useMutation((isSuspended: boolean) => - axiosSec.put(`user/${id}`, { - suspended: isSuspended, - }), - ); -export const useResetMfa = ({ id }: { id: string }) => useMutation(() => axiosSec.put(`user/${id}?resetMFA=true`, {})); +export const useSuspendUser = ({ id }: { id: string }) => { + const queryClient = useQueryClient(); -export const useResetPassword = ({ id }: { id: string }) => - useMutation(() => axiosSec.put(`user/${id}?forgotPassword=true`, {})); + return useMutation( + (isSuspended: boolean) => + axiosSec.put(`user/${id}`, { + suspended: isSuspended, + }), + { + onSuccess: () => { + queryClient.invalidateQueries(['users']); + }, + }, + ); +}; + +export const useResetMfa = ({ id }: { id: string }) => { + const queryClient = useQueryClient(); + + return useMutation(() => axiosSec.put(`user/${id}?resetMFA=true`, {}), { + onSuccess: () => { + queryClient.invalidateQueries(['users']); + }, + }); +}; + +export const useResetPassword = ({ id }: { id: string }) => { + const queryClient = useQueryClient(); + + return useMutation(() => axiosSec.put(`user/${id}?forgotPassword=true`, {}), { + onSuccess: () => { + queryClient.invalidateQueries(['users']); + }, + }); +}; const deleteUser = async (userId: string) => axiosSec.delete(`/user/${userId}`); export const useDeleteUser = () => { diff --git a/src/pages/LoginPage/LoginForm.tsx b/src/pages/LoginPage/LoginForm.tsx index 50a4409..2e078fb 100644 --- a/src/pages/LoginPage/LoginForm.tsx +++ b/src/pages/LoginPage/LoginForm.tsx @@ -48,7 +48,10 @@ const _LoginForm: React.FC<_LoginFormProps> = ({ setActiveForm }) => { const displayError = useMemo(() => { const loginError: AxiosError = error as AxiosError; - if (loginError?.response?.data?.ErrorCode === 4) return t('login.waiting_for_email_verification'); + if (loginError?.response?.data?.ErrorCode === 5) return t('login.waiting_for_email_verification'); + if (loginError?.response?.data?.ErrorCode === 15) { + return t('login.suspended_error'); + } return t('login.invalid_credentials'); }, [t, error]); diff --git a/src/pages/UsersPage/Table/ActionsDropdown.tsx b/src/pages/UsersPage/Table/ActionsDropdown.tsx index 19773f1..d87d6c8 100644 --- a/src/pages/UsersPage/Table/ActionsDropdown.tsx +++ b/src/pages/UsersPage/Table/ActionsDropdown.tsx @@ -12,9 +12,11 @@ interface Props { isSuspended: boolean; isWaitingForCheck: boolean; refresh: () => void; + isDisabled?: boolean; + size?: 'sm' | 'md' | 'lg'; } -const UserActions: React.FC = ({ id, isSuspended, isWaitingForCheck, refresh }) => { +const UserActions = ({ id, isSuspended, isWaitingForCheck, refresh, size = 'sm', isDisabled }: Props) => { const { t } = useTranslation(); const toast = useToast(); const { mutateAsync: sendValidation } = useSendUserEmailValidation({ id, refresh }); @@ -76,9 +78,16 @@ const UserActions: React.FC = ({ id, isSuspended, isWaitingForCheck, refr return ( - } size="sm" ml={2} /> + } + size={size} + ml={2} + isDisabled={isDisabled} + /> - + {isSuspended ? t('users.reactivate_user') : t('users.suspend')} diff --git a/src/pages/UsersPage/Table/EditUserModal/Form.tsx b/src/pages/UsersPage/Table/EditUserModal/Form.tsx index cf50b07..657dfb8 100644 --- a/src/pages/UsersPage/Table/EditUserModal/Form.tsx +++ b/src/pages/UsersPage/Table/EditUserModal/Form.tsx @@ -62,7 +62,7 @@ const UpdateUserForm = ({ editing, isOpen, onClose, selectedUser, formRef }: Pro useEffect(() => { setFormKey(uuid()); - }, [isOpen]); + }, [isOpen, editing]); return ( { const { t } = useTranslation(); const [editing, setEditing] = useBoolean(); + const queryClient = useQueryClient(); const { isOpen: showConfirm, onOpen: openConfirm, onClose: closeConfirm } = useDisclosure(); const { form, formRef } = useFormRef(); const canFetchUser = userId !== '' && isOpen; - const { data: user, isFetching } = useGetUser({ id: userId ?? '', enabled: canFetchUser }); + const { data: user, isFetching, refetch } = useGetUser({ id: userId ?? '', enabled: canFetchUser }); const closeModal = () => (form.dirty ? openConfirm() : onClose()); @@ -31,6 +34,11 @@ const EditUserModal = ({ isOpen, onClose, userId }: Props) => { onClose(); }; + const refresh = () => { + refetch(); + queryClient.invalidateQueries(['users']); + }; + useEffect(() => { if (isOpen) setEditing.off(); }, [isOpen]); @@ -40,15 +48,40 @@ const EditUserModal = ({ isOpen, onClose, userId }: Props) => { + {user?.suspended ? ( + + {t('user.suspended')} + + ) : null} + {user?.waitingForEmailCheck ? ( + + {t('user.email_not_validated')} + + ) : null} + + } topRightButtons={ <>