[WIFI-12223] User table state fix, with label correction and API logic update

Signed-off-by: Charles <charles.bourque96@gmail.com>
This commit is contained in:
Charles
2023-01-25 21:22:17 +01:00
parent 7767043a5a
commit 1cfd3a10ad
16 changed files with 204 additions and 60 deletions

4
package-lock.json generated
View File

@@ -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",

View File

@@ -1,6 +1,6 @@
{
"name": "ucentral-client",
"version": "2.9.0(2)",
"version": "2.9.0(3)",
"description": "",
"private": true,
"main": "index.tsx",

View File

@@ -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": {

View File

@@ -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": {

View File

@@ -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": {

View File

@@ -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": {

View File

@@ -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": {

View File

@@ -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<ModalHeaderProps> = ({ title, right }) => (
const _ModalHeader: React.FC<ModalHeaderProps> = ({ title, left, right }) => (
<Header>
<Flex justifyContent="center" alignItems="center" maxW="100%" px={1}>
{title}
<HStack spacing={2} ml={2}>
{left ?? null}
</HStack>
<Spacer />
{right}
</Flex>

View File

@@ -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 }:
<ModalContent maxWidth={maxWidth}>
<ModalHeader
title={title}
left={tags}
right={
<HStack spacing={2}>
{topRightButtons}

View File

@@ -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 }) =>

View File

@@ -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 = () => {

View File

@@ -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]);

View File

@@ -12,9 +12,11 @@ interface Props {
isSuspended: boolean;
isWaitingForCheck: boolean;
refresh: () => void;
isDisabled?: boolean;
size?: 'sm' | 'md' | 'lg';
}
const UserActions: React.FC<Props> = ({ 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<Props> = ({ id, isSuspended, isWaitingForCheck, refr
return (
<Menu>
<Tooltip label={t('commands.other')}>
<MenuButton as={IconButton} aria-label="Commands" icon={<Wrench size={20} />} size="sm" ml={2} />
<MenuButton
as={IconButton}
aria-label="Commands"
icon={<Wrench size={20} />}
size={size}
ml={2}
isDisabled={isDisabled}
/>
</Tooltip>
<MenuList>
<MenuList fontSize="md">
<MenuItem onClick={handleSuspendClick}>
{isSuspended ? t('users.reactivate_user') : t('users.suspend')}
</MenuItem>

View File

@@ -62,7 +62,7 @@ const UpdateUserForm = ({ editing, isOpen, onClose, selectedUser, formRef }: Pro
useEffect(() => {
setFormKey(uuid());
}, [isOpen]);
}, [isOpen, editing]);
return (
<Formik

View File

@@ -1,12 +1,14 @@
import * as React from 'react';
import { useEffect } from 'react';
import { Spinner, Center, useDisclosure, useBoolean } from '@chakra-ui/react';
import { Spinner, Center, useDisclosure, useBoolean, Tag } from '@chakra-ui/react';
import { useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { EditButton } from '../../../../components/Buttons/EditButton';
import { SaveButton } from '../../../../components/Buttons/SaveButton';
import { ConfirmCloseAlertModal } from '../../../../components/Modals/ConfirmCloseAlert';
import { Modal } from '../../../../components/Modals/Modal';
import ActionsDropdown from '../ActionsDropdown';
import UpdateUserForm from './Form';
import { ToggleEditButton } from 'components/Buttons/ToggleEditButton';
import { useGetUser, User } from 'hooks/Network/Users';
import { useFormRef } from 'hooks/useFormRef';
@@ -19,10 +21,11 @@ type Props = {
const EditUserModal = ({ isOpen, onClose, userId }: Props) => {
const { t } = useTranslation();
const [editing, setEditing] = useBoolean();
const queryClient = useQueryClient();
const { isOpen: showConfirm, onOpen: openConfirm, onClose: closeConfirm } = useDisclosure();
const { form, formRef } = useFormRef<User>();
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) => {
<Modal
isOpen={isOpen}
onClose={closeModal}
title={t('crud.edit_obj', { obj: t('user.title') })}
title={user?.name ?? t('crud.edit_obj', { obj: t('user.title') })}
tags={
<>
{user?.suspended ? (
<Tag colorScheme="yellow" size="lg">
{t('user.suspended')}
</Tag>
) : null}
{user?.waitingForEmailCheck ? (
<Tag colorScheme="blue" size="lg">
{t('user.email_not_validated')}
</Tag>
) : null}
</>
}
topRightButtons={
<>
<SaveButton
onClick={form.submitForm}
isLoading={form.isSubmitting}
isDisabled={!editing || !form.isValid || !form.dirty}
hidden={!editing}
/>
<EditButton ml={2} isDisabled={editing} onClick={setEditing.toggle} isCompact />
<ToggleEditButton ml={2} isEditing={editing} toggleEdit={setEditing.toggle} isDirty={form.dirty} />
{user ? (
<ActionsDropdown
id={user?.id}
isSuspended={user?.suspended}
isWaitingForCheck={user?.waitingForEmailCheck}
refresh={refresh}
size="md"
isDisabled={editing}
/>
) : null}
</>
}
>

View File

@@ -141,7 +141,7 @@ const UserTable = () => {
<Box overflowX="auto" w="100%">
<DataTable
columns={columns as Column<object>[]}
data={users?.filter((curr) => curr.email !== user?.email) ?? []}
data={users ?? []}
isLoading={isFetching}
obj={t('users.title')}
sortBy={[{ id: 'email', desc: false }]}