Merge pull request #168 from stephb9959/main

[WIFI-12224] User table cache fixes
This commit is contained in:
Charles Bourque
2023-01-26 12:19:36 +01:00
committed by GitHub
37 changed files with 807 additions and 401 deletions

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "wlan-cloud-owprov-ui",
"version": "2.9.0(1)",
"version": "2.9.0(3)",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "wlan-cloud-owprov-ui",
"version": "2.9.0(1)",
"version": "2.9.0(3)",
"license": "ISC",
"dependencies": {
"@chakra-ui/icons": "^2.0.11",

View File

@@ -1,6 +1,6 @@
{
"name": "wlan-cloud-owprov-ui",
"version": "2.9.0(1)",
"version": "2.9.0(3)",
"description": "",
"main": "index.tsx",
"scripts": {

View File

@@ -602,6 +602,7 @@
"certificate_expires_in": "Zertifikat läuft ab in",
"certificate_expiry": "Zert. Läuft ab in",
"connected": "In Verbindung gebracht",
"crash_logs": "Absturzprotokolle",
"create_errors": "Fehler beim Versuch, Geräte zu erstellen",
"create_success": " Geräte erfolgreich erstellt",
"current_firmware": "Aktuelle Firmware",
@@ -615,6 +616,7 @@
"import_device_warning": "Bitte stellen Sie sicher, dass am Anfang oder Ende von Werten keine zusätzlichen Leerzeichen stehen, es sei denn, es handelt sich um einen Teil des gewünschten Werts",
"import_explanation": "Für den Massenimport von Geräten müssen Sie eine CSV-Datei mit den folgenden Spalten verwenden: SerialNumber, DeviceType, Name, Description, Note",
"invalid_serial_number": "Ungültige Seriennummer (muss 12 HEX-Zeichen lang sein)",
"logs_one": "Log",
"new_devices": "Neue Geräte",
"no_model_image": "Kein Modellbild gefunden",
"not_connected": "Nicht verbunden",
@@ -799,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!",
@@ -1055,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

@@ -602,6 +602,7 @@
"certificate_expires_in": "Certificate Expiry",
"certificate_expiry": "Cert. Expires In",
"connected": "Connected",
"crash_logs": "Crash Logs",
"create_errors": "errors while trying to create devices",
"create_success": " devices successfully created",
"current_firmware": "Current Firmware",
@@ -615,6 +616,7 @@
"import_device_warning": "Please make sure there are no extra spaces at the start or end of any values unless it is part of the value desired",
"import_explanation": "To bulk import devices, you need to use a CSV file with the following columns: SerialNumber, DeviceType, Name, Description, Note",
"invalid_serial_number": "Invalid Serial Number (needs to be 12 HEX chars)",
"logs_one": "Log",
"new_devices": "new devices",
"no_model_image": "No Model Image Found",
"not_connected": "Not Connected",
@@ -799,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!",
@@ -1055,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

@@ -602,6 +602,7 @@
"certificate_expires_in": "El certificado caduca en",
"certificate_expiry": "Cert. Expira en",
"connected": "Conectado",
"crash_logs": "Registros de fallas",
"create_errors": "errores al intentar crear dispositivos",
"create_success": " dispositivos creados con éxito",
"current_firmware": "Firmware actual",
@@ -615,6 +616,7 @@
"import_device_warning": "Asegúrese de que no haya espacios adicionales al principio o al final de ningún valor a menos que sea parte del valor deseado",
"import_explanation": "Para importar dispositivos de forma masiva, debe usar un archivo CSV con las siguientes columnas: Número de serie, Tipo de dispositivo, Nombre, Descripción, Nota",
"invalid_serial_number": "Número de serie no válido (debe tener 12 caracteres HEX)",
"logs_one": "Iniciar sesión",
"new_devices": "Nuevos dispositivos",
"no_model_image": "No se encontró ninguna imagen de modelo",
"not_connected": "No conectado",
@@ -799,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!",
@@ -1055,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

@@ -602,6 +602,7 @@
"certificate_expires_in": "Le certificat expire dans",
"certificate_expiry": "Cert. Expire dans",
"connected": "Connecté",
"crash_logs": "Journaux des plantages",
"create_errors": "erreurs lors de la tentative de création d'appareils",
"create_success": " appareils créés avec succès",
"current_firmware": "Firmware actuel",
@@ -615,6 +616,7 @@
"import_device_warning": "Veuillez vous assurer qu'il n'y a pas d'espaces supplémentaires au début ou à la fin des valeurs, sauf si cela fait partie de la valeur souhaitée",
"import_explanation": "Pour importer en masse des appareils, vous devez utiliser un fichier CSV avec les colonnes suivantes : SerialNumber, DeviceType, Name, Description, Note",
"invalid_serial_number": "Numéro de série non valide (doit être composé de 12 caractères HEX)",
"logs_one": "Bûche",
"new_devices": "nouveaux appareils",
"no_model_image": "Aucune image de modèle trouvée",
"not_connected": "Pas connecté",
@@ -799,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!",
@@ -1055,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

@@ -602,6 +602,7 @@
"certificate_expires_in": "Certificado expira em",
"certificate_expiry": "Certificado expira em",
"connected": "Conectado",
"crash_logs": "Registros de falhas",
"create_errors": "erros ao tentar criar dispositivos",
"create_success": " dispositivos criados com sucesso",
"current_firmware": "Firmware atual",
@@ -615,6 +616,7 @@
"import_device_warning": "Certifique-se de que não há espaços extras no início ou no final de nenhum valor, a menos que faça parte do valor desejado",
"import_explanation": "Para importar dispositivos em massa, você precisa usar um arquivo CSV com as seguintes colunas: SerialNumber, DeviceType, Name, Description, Note",
"invalid_serial_number": "Número de série inválido (precisa ter 12 caracteres HEX)",
"logs_one": "Registro",
"new_devices": "novos dispositivos",
"no_model_image": "Nenhuma imagem de modelo encontrada",
"not_connected": "Não conectado",
@@ -799,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!",
@@ -1055,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

@@ -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,8 +16,7 @@ export type ModalProps = {
children: React.ReactElement;
};
// eslint-disable-next-line @typescript-eslint/naming-convention
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;
@@ -33,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

@@ -1,22 +0,0 @@
import React from 'react';
import { Flex, ModalHeader as Header, Spacer } from '@chakra-ui/react';
import PropTypes from 'prop-types';
const propTypes = {
title: PropTypes.string.isRequired,
right: PropTypes.node.isRequired,
};
const ModalHeader = ({ title, right }) => (
<Header>
<Flex justifyContent="center" alignItems="center" maxW="100%" px={1}>
{title}
<Spacer />
{right}
</Flex>
</Header>
);
ModalHeader.propTypes = propTypes;
export default ModalHeader;

View File

@@ -0,0 +1,23 @@
import React from 'react';
import { Flex, HStack, ModalHeader as Header, Spacer } from '@chakra-ui/react';
interface ModalHeaderProps {
title: string;
left?: React.ReactNode;
right: React.ReactNode;
}
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>
</Header>
);
export default ModalHeader;

View File

@@ -87,6 +87,12 @@ const ConfigurationsTable = ({ select, actions }) => {
data={configurations ?? []}
isLoading={isFetching}
obj={t('configurations.title')}
sortBy={[
{
id: 'name',
desc: false,
},
]}
minHeight="200px"
/>
);

View File

@@ -99,6 +99,12 @@ const ContactTable = ({ actions, select, ignoredColumns, refreshId, disabledIds
data={venues ?? []}
isLoading={isFetching}
obj={t('contacts.other')}
sortBy={[
{
id: 'name',
desc: false,
},
]}
minHeight="200px"
/>
);

View File

@@ -78,6 +78,12 @@ const EntityTable = ({ actions, select }) => {
data={entities ?? []}
isLoading={isFetching}
obj={t('entities.title')}
sortBy={[
{
id: 'name',
desc: false,
},
]}
minHeight="200px"
/>
);

View File

@@ -193,6 +193,12 @@ const InventoryTable = ({
isLoading={isFetchingCount || isFetchingTags}
isManual={!isManual}
obj={t('devices.title')}
sortBy={[
{
id: 'serialNumber',
desc: false,
},
]}
count={count || 0}
setPageInfo={setPageInfo}
minHeight={minHeight ?? '200px'}
@@ -207,6 +213,12 @@ const InventoryTable = ({
isLoading={isFetchingCount || isFetchingTags}
isManual={!isManual}
obj={t('devices.title')}
sortBy={[
{
id: 'serialNumber',
desc: false,
},
]}
count={count || 0}
setPageInfo={setPageInfo}
minHeight={minHeight ?? '200px'}

View File

@@ -105,6 +105,12 @@ const LocationTable = ({ actions, select, refreshId, ignoredColumns, disabledIds
data={venues ?? []}
isLoading={isFetching}
obj={t('locations.other')}
sortBy={[
{
id: 'name',
desc: false,
},
]}
minHeight="200px"
/>
);

View File

@@ -93,6 +93,12 @@ const ResourcesTable = ({ select, actions, refreshId }) => {
data={resources ?? []}
isLoading={isFetching}
obj={t('resources.title')}
sortBy={[
{
id: 'name',
desc: false,
},
]}
minHeight="200px"
/>
);

View File

@@ -119,6 +119,12 @@ const SubscriberDeviceTable = ({
data={subscriberDevices ?? []}
isLoading={isFetching}
obj={t('devices.title')}
sortBy={[
{
id: 'serialNumber',
desc: false,
},
]}
minHeight={minHeight ?? '200px'}
/>
);

View File

@@ -78,6 +78,12 @@ const VenueTable = ({ actions, select }) => {
data={venues ?? []}
isLoading={isFetching}
obj={t('venues.title')}
sortBy={[
{
id: 'name',
desc: false,
},
]}
minHeight="200px"
/>
);

View File

@@ -1,7 +1,8 @@
import { useToast } from '@chakra-ui/react';
import { useMutation, useQuery } from '@tanstack/react-query';
import { AxiosError } from 'axios';
import { useTranslation } from 'react-i18next';
import { AxiosError } from 'models/Axios';
import { ContactObj } from 'models/Contact';
import { PageInfo } from 'models/Table';
import { axiosProv } from 'utils/axiosInstances';
@@ -102,32 +103,47 @@ export const useGetSelectContacts = ({ select }: { select: string[] }) => {
);
};
const getContactsBatch = async (limit: number, offset: number) =>
axiosProv
.get(`contact?withExtendedInfo=true&limit=${limit}&offset=${offset}`)
.then(({ data }) => data.contacts as ContactObj[]);
const getAllContacts = async () => {
const limit = 500;
let offset = 0;
let data: ContactObj[] = [];
let lastResponse: ContactObj[] = [];
do {
// eslint-disable-next-line no-await-in-loop
lastResponse = await getContactsBatch(limit, offset);
data = data.concat(lastResponse);
offset += 500;
} while (lastResponse.length === 500);
return data;
};
export const useGetAllContacts = () => {
const { t } = useTranslation();
const toast = useToast();
return useQuery(
['get-all-contacts'],
() => axiosProv.get(`contact?withExtendedInfo=true&limit=500&offset=0`).then(({ data }) => data.contacts),
{
staleTime: 1000 * 1000,
onError: (e: AxiosError) => {
if (!toast.isActive('contacts-fetching-error'))
toast({
id: 'contacts-fetching-error',
title: t('common.error'),
description: t('crud.error_fetching_obj', {
obj: t('contacts.other'),
e: e?.response?.data?.ErrorDescription,
}),
status: 'error',
duration: 5000,
isClosable: true,
position: 'top-right',
});
},
return useQuery(['get-all-contacts'], () => getAllContacts(), {
staleTime: 60 * 1000,
onError: (e: AxiosError) => {
if (!toast.isActive('contacts-fetching-error'))
toast({
id: 'contacts-fetching-error',
title: t('common.error'),
description: t('crud.error_fetching_obj', {
obj: t('contacts.other'),
e: e?.response?.data?.ErrorDescription,
}),
status: 'error',
duration: 5000,
isClosable: true,
position: 'top-right',
});
},
);
});
};
export const useGetContact = ({ enabled, id }: { enabled: boolean; id: string }) => {

View File

@@ -1,8 +1,8 @@
import { useToast } from '@chakra-ui/react';
import { useMutation, useQuery } from '@tanstack/react-query';
import { AxiosError } from 'axios';
import { useTranslation } from 'react-i18next';
import useDefaultPage from 'hooks/useDefaultPage';
import { AxiosError } from 'models/Axios';
import { Entity } from 'models/Entity';
import { axiosProv, axiosSec } from 'utils/axiosInstances';
@@ -32,35 +32,47 @@ export const useGetEntityTree = () => {
});
};
const getEntitiesBatch = async (limit: number, offset: number) =>
axiosProv
.get(`entity?withExtendedInfo=true&offset=${offset}&limit=${limit}`)
.then(({ data }: { data: { entities: Entity[] } }) => data.entities);
const getAllEntities = async () => {
const limit = 500;
let offset = 0;
let data: Entity[] = [];
let lastResponse: Entity[] = [];
do {
// eslint-disable-next-line no-await-in-loop
lastResponse = await getEntitiesBatch(limit, offset);
data = data.concat(lastResponse);
offset += limit;
} while (lastResponse.length === limit);
return data;
};
export const useGetEntities = () => {
const { t } = useTranslation();
const toast = useToast();
return useQuery(
['get-entities'],
() =>
axiosProv
.get('entity?withExtendedInfo=true&offset=0&limit=500')
.then(({ data }: { data: { entities: Entity[] } }) => data.entities),
{
staleTime: 30000,
onError: (e: AxiosError) => {
if (!toast.isActive('entities-fetching-error'))
toast({
id: 'entities-fetching-error',
title: t('common.error'),
description: t('crud.error_fetching_obj', {
obj: t('entities.title'),
e: e?.response?.data?.ErrorDescription,
}),
status: 'error',
duration: 5000,
isClosable: true,
position: 'top-right',
});
},
return useQuery(['get-entities'], () => getAllEntities(), {
staleTime: 30000,
onError: (e: AxiosError) => {
if (!toast.isActive('entities-fetching-error'))
toast({
id: 'entities-fetching-error',
title: t('common.error'),
description: t('crud.error_fetching_obj', {
obj: t('entities.title'),
e: e?.response?.data?.ErrorDescription,
}),
status: 'error',
duration: 5000,
isClosable: true,
position: 'top-right',
});
},
);
});
};
export const useGetSelectEntities = ({ select }: { select: string[] }) => {

View File

@@ -1,40 +1,54 @@
import { useToast } from '@chakra-ui/react';
import { useMutation, useQuery } from '@tanstack/react-query';
import { AxiosError } from 'axios';
import { useTranslation } from 'react-i18next';
import { v4 as uuid } from 'uuid';
import { AxiosError } from 'models/Axios';
import { Firmware } from 'models/Firmware';
import { axiosFms, axiosGw } from 'utils/axiosInstances';
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 useUpdateDeviceFirmware = ({ serialNumber, onClose }: { serialNumber: string; onClose: () => void }) => {

View File

@@ -1,7 +1,7 @@
import { useToast } from '@chakra-ui/react';
import { useMutation, useQuery } from '@tanstack/react-query';
import { AxiosError } from 'axios';
import { useTranslation } from 'react-i18next';
import { AxiosError } from 'models/Axios';
import { PageInfo } from 'models/Table';
import { axiosProv } from 'utils/axiosInstances';
@@ -107,35 +107,53 @@ export const useGetSelectLocations = ({ select, enabled = true }: { select: stri
);
};
export const useGetAllLocations = ({ venueId }: { venueId: string[] }) => {
const getLocationsBatch = async (limit: number, offset: number, venueId?: string) =>
axiosProv
.get(
`${
venueId
? `venue?locationsForVenue=${venueId}&limit=${limit}&offset=${offset}`
: `location?withExtendedInfo=true&limit=${limit}&offset=${offset}`
}`,
)
.then(({ data }) => data.locations as Location[]);
const getAllLocations = async (venueId?: string) => {
const limit = 500;
let offset = 0;
let data: Location[] = [];
let lastResponse: Location[] = [];
do {
// eslint-disable-next-line no-await-in-loop
lastResponse = await getLocationsBatch(limit, offset, venueId);
data = data.concat(lastResponse);
offset += limit;
} while (lastResponse.length === limit && venueId === undefined);
return data;
};
export const useGetAllLocations = ({ venueId }: { venueId?: string }) => {
const { t } = useTranslation();
const toast = useToast();
return useQuery(
['get-all-locations', venueId],
() =>
axiosProv
.get(`${venueId ? `venue?locationsForVenue=${venueId}` : 'location?withExtendedInfo=true&limit=500&offset=0'}`)
.then(({ data }) => data.locations),
{
staleTime: 1000 * 1000,
onError: (e: AxiosError) => {
if (!toast.isActive('locations-fetching-error'))
toast({
id: 'locations-fetching-error',
title: t('common.error'),
description: t('crud.error_fetching_obj', {
obj: t('locations.other'),
e: e?.response?.data?.ErrorDescription,
}),
status: 'error',
duration: 5000,
isClosable: true,
position: 'top-right',
});
},
return useQuery(['get-all-locations', venueId], () => getAllLocations(venueId), {
staleTime: 1000 * 60,
onError: (e: AxiosError) => {
if (!toast.isActive('locations-fetching-error'))
toast({
id: 'locations-fetching-error',
title: t('common.error'),
description: t('crud.error_fetching_obj', {
obj: t('locations.other'),
e: e?.response?.data?.ErrorDescription,
}),
status: 'error',
duration: 5000,
isClosable: true,
position: 'top-right',
});
},
);
});
};
export const useGetLocation = ({ enabled, id }: { enabled: boolean; id: string }) => {

View File

@@ -1,7 +1,7 @@
import { useToast } from '@chakra-ui/react';
import { useMutation, useQuery } from '@tanstack/react-query';
import { AxiosError } from 'axios';
import { useTranslation } from 'react-i18next';
import { AxiosError } from 'models/Axios';
import { PageInfo } from 'models/Table';
import { VariableBlock } from 'models/VariableBlock';
import { axiosProv } from 'utils/axiosInstances';
@@ -33,31 +33,44 @@ export const useGetResourcesCount = () => {
);
};
const getResourceBatch = async (limit: number, offset: number) =>
axiosProv.get(`variable?limit=${limit}&offset=${offset}`).then(({ data }) => data.variableBlocks as unknown[]);
const getAllResources = async () => {
const limit = 500;
let offset = 0;
let data: unknown[] = [];
let lastResponse: unknown[] = [];
do {
// eslint-disable-next-line no-await-in-loop
lastResponse = await getResourceBatch(limit, offset);
data = data.concat(lastResponse);
offset += limit;
} while (lastResponse.length === limit);
return data;
};
export const useGetAllResources = () => {
const { t } = useTranslation();
const toast = useToast();
return useQuery(
['get-all-resources'],
() => axiosProv.get(`variable?limit=500`).then(({ data }) => data.variableBlocks),
{
onError: (e: AxiosError) => {
if (!toast.isActive('resource-fetching-error'))
toast({
id: 'resource-fetching-error',
title: t('common.error'),
description: t('crud.error_fetching_obj', {
obj: t('resources.configuration_resource'),
e: e?.response?.data?.ErrorDescription,
}),
status: 'error',
duration: 5000,
isClosable: true,
position: 'top-right',
});
},
return useQuery(['get-all-resources'], () => getAllResources(), {
onError: (e: AxiosError) => {
if (!toast.isActive('resource-fetching-error'))
toast({
id: 'resource-fetching-error',
title: t('common.error'),
description: t('crud.error_fetching_obj', {
obj: t('resources.configuration_resource'),
e: e?.response?.data?.ErrorDescription,
}),
status: 'error',
duration: 5000,
isClosable: true,
position: 'top-right',
});
},
);
});
};
export const useGetResources = ({

View File

@@ -1,16 +1,81 @@
import { useToast } from '@chakra-ui/react';
import { useMutation, useQuery } from '@tanstack/react-query';
import { QueryClient, useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { AxiosError } from 'axios';
import { useTranslation } from 'react-i18next';
import { User } from 'models/User';
import { AtLeast } from 'models/General';
import { Note } from 'models/Note';
import { axiosSec } from 'utils/axiosInstances';
const getAvatarPromises = (userList: User[]) => {
export type UserRole =
| 'root'
| 'admin'
| 'subscriber'
| 'partner'
| 'csr'
| 'system'
| 'installer'
| 'noc'
| 'accounting';
export type User = {
avatar: string;
blackListed: boolean;
creationDate: number;
currentLoginURI: string;
currentPassword: string;
description: string;
email: string;
id: string;
lastEmailCheck: number;
lastLogin: number;
lastPasswordChange: number;
lastPasswords: string[];
locale: string;
location: string;
modified: number;
name: string;
notes: Note[];
oauthType: string;
oauthUserInfo: string;
owner: string;
securityPolicy: string;
securityPolicyChange: number;
signingUp: string;
suspended: boolean;
userRole: UserRole;
userTypeProprietaryInfo: {
authenticatorSecret: string;
mfa: {
enabled: boolean;
method?: 'authenticator' | 'sms' | 'email' | '';
};
mobiles: { number: string }[];
};
validated: boolean;
validationDate: number;
validationEmail: string;
validationURI: string;
waitingForEmailCheck: boolean;
};
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('');
});
@@ -18,28 +83,57 @@ const getAvatarPromises = (userList: User[]) => {
return promises;
};
export const useGetUsers = ({ setUsersWithAvatars }: { setUsersWithAvatars: (users: unknown) => void }) => {
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[]);
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(
// @ts-ignore
new Uint8Array(response.value.data).reduce((respData, byte) => respData + String.fromCharCode(byte), ''),
);
return `data:;base64,${base64}`;
}
return '';
}),
);
return users.map((newUser: User, i: number) => ({ ...newUser, avatar: avatars[i] })) as User[];
};
export const useGetUsers = () => {
const { t } = useTranslation();
const toast = useToast();
const queryClient = useQueryClient();
return useQuery(['get-users'], () => axiosSec.get('users').then(({ data }) => data.users), {
onSuccess: async (users) => {
const avatars = await Promise.allSettled(getAvatarPromises(users)).then((results) =>
results.map((response) => {
if (response.status === 'fulfilled' && response?.value !== '') {
const base64 = btoa(
// @ts-ignore
new Uint8Array(response.value.data).reduce((respData, byte) => respData + String.fromCharCode(byte), ''),
);
return `data:;base64,${base64}`;
}
return '';
}),
);
const newUsers = users.map((newUser: User, i: number) => ({ ...newUser, avatar: avatars[i] }));
setUsersWithAvatars(newUsers);
},
return useQuery(['users'], () => getUsers(queryClient), {
staleTime: 30 * 1000,
onError: (e: AxiosError) => {
if (!toast.isActive('users-fetching-error'))
toast({
@@ -62,24 +156,28 @@ export const useGetUser = ({ id, enabled }: { id: string; enabled: boolean }) =>
const { t } = useTranslation();
const toast = useToast();
return useQuery(['get-user', id], () => axiosSec.get(`user/${id}?withExtendedInfo=true`).then(({ data }) => data), {
enabled,
onError: (e: AxiosError) => {
if (!toast.isActive('user-fetching-error'))
toast({
id: 'user-fetching-error',
title: t('common.error'),
description: t('crud.error_fetching_obj', {
obj: t('users.one'),
e: e?.response?.data?.ErrorDescription,
}),
status: 'error',
duration: 5000,
isClosable: true,
position: 'top-right',
});
return useQuery(
['users', id],
() => axiosSec.get(`user/${id}?withExtendedInfo=true`).then(({ data }) => data as User),
{
enabled,
onError: (e: AxiosError) => {
if (!toast.isActive('user-fetching-error'))
toast({
id: 'user-fetching-error',
title: t('common.error'),
description: t('crud.error_fetching_obj', {
obj: t('users.one'),
e: e?.response?.data?.ErrorDescription,
}),
status: 'error',
duration: 5000,
isClosable: true,
position: 'top-right',
});
},
},
});
);
};
export const useSendUserEmailValidation = ({ id, refresh }: { id: string; refresh: () => void }) => {
@@ -114,13 +212,80 @@ 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 = () => {
const queryClient = useQueryClient();
return useMutation(deleteUser, {
onSuccess: () => {
queryClient.invalidateQueries(['users']);
},
});
};
const createUser = async (newUser: {
name: string;
description?: string;
email: string;
currentPassword: string;
notes?: { note: string }[];
userRole: string;
emailValidation: boolean;
changePassword: boolean;
}) => axiosSec.post(`user/0${newUser.emailValidation ? '?email_verification=true' : ''}`, newUser);
export const useCreateUser = () => {
const queryClient = useQueryClient();
return useMutation(createUser, {
onSuccess: () => {
queryClient.invalidateQueries(['users']);
},
});
};
const modifyUser = async (newUser: AtLeast<User, 'id'>) => axiosSec.put(`user/${newUser.id}`, newUser);
export const useUpdateUser = () => {
const queryClient = useQueryClient();
return useMutation(modifyUser, {
onSuccess: () => {
queryClient.invalidateQueries(['users']);
},
});
};

View File

@@ -1,37 +1,53 @@
import { useToast } from '@chakra-ui/react';
import { useMutation, useQuery } from '@tanstack/react-query';
import { AxiosError } from 'axios';
import { useTranslation } from 'react-i18next';
import { v4 as uuid } from 'uuid';
import useDefaultPage from 'hooks/useDefaultPage';
import { AxiosError } from 'models/Axios';
import { Venue } from 'models/Venue';
import { axiosProv } from 'utils/axiosInstances';
const getVenuesBatch = async (limit: number, offset: number) =>
axiosProv
.get(`venue?withExtendedInfo=true&offset=${offset}&limit=${limit}`)
.then(({ data }) => data.venues as Venue[]);
const getAllVenues = async () => {
const limit = 500;
let offset = 0;
let data: Venue[] = [];
let lastResponse: Venue[] = [];
do {
// eslint-disable-next-line no-await-in-loop
lastResponse = await getVenuesBatch(limit, offset);
data = data.concat(lastResponse);
offset += limit;
} while (lastResponse.length === limit);
return data;
};
export const useGetVenues = () => {
const { t } = useTranslation();
const toast = useToast();
return useQuery(
['get-venues'],
() => axiosProv.get('venue?withExtendedInfo=true&offset=0&limit=500').then(({ data }) => data.venues),
{
staleTime: 30000,
onError: (e: AxiosError) => {
if (!toast.isActive('venues-fetching-error'))
toast({
id: 'venues-fetching-error',
title: t('common.error'),
description: t('crud.error_fetching_obj', {
obj: t('venues.title'),
e: e?.response?.data?.ErrorDescription,
}),
status: 'error',
duration: 5000,
isClosable: true,
position: 'top-right',
});
},
return useQuery(['get-venues'], () => getAllVenues(), {
staleTime: 30000,
onError: (e: AxiosError) => {
if (!toast.isActive('venues-fetching-error'))
toast({
id: 'venues-fetching-error',
title: t('common.error'),
description: t('crud.error_fetching_obj', {
obj: t('venues.title'),
e: e?.response?.data?.ErrorDescription,
}),
status: 'error',
duration: 5000,
isClosable: true,
position: 'top-right',
});
},
);
});
};
export const useGetSelectVenues = ({ select }: { select: string[] }) => {

View File

@@ -2,7 +2,7 @@ import { Ref, useCallback, useMemo, useState } from 'react';
import { FormikProps } from 'formik';
import { FormType } from 'models/Form';
const useFormRef = () => {
const useFormRef = <Type = Record<string, unknown>>() => {
const [form, setForm] = useState<FormType>({
submitForm: () => {},
isSubmitting: false,
@@ -10,7 +10,7 @@ const useFormRef = () => {
dirty: false,
});
const formRef = useCallback(
(node: FormikProps<Record<string, unknown>> | undefined) => {
(node: FormikProps<Type>) => {
if (
node &&
(form.submitForm !== node.submitForm ||
@@ -22,7 +22,7 @@ const useFormRef = () => {
}
},
[form],
) as Ref<FormikProps<Record<string, unknown>>> | undefined;
) as Ref<FormikProps<Type>>;
const toReturn = useMemo(() => ({ form, formRef }), [form]);

View File

@@ -1,5 +1,67 @@
import { Note } from './Note';
export type CreateContactObj = {
name: string;
description?: string;
notes?: Note[];
id?: string;
type:
| 'SUBSCRIBER'
| 'USER'
| 'INSTALLER'
| 'CSR'
| 'MANAGER'
| 'BUSINESSOWNER'
| 'TECHNICIAN'
| 'CORPORATE'
| 'UNKNOWN';
title?: string;
salutation?: string;
firstname: string;
lastname?: string;
initials?: string;
visual?: string;
phones: string[];
mobiles: string[];
primaryEmail: string;
secondaryEmail?: string;
accessPIN: string;
inUse?: string[];
entity: string;
};
export type ContactObj = {
name: string;
description: string;
notes: Note[];
id: string;
created: number;
modified: number;
type:
| 'SUBSCRIBER'
| 'USER'
| 'INSTALLER'
| 'CSR'
| 'MANAGER'
| 'BUSINESSOWNER'
| 'TECHNICIAN'
| 'CORPORATE'
| 'UNKNOWN';
title: string;
salutation: string;
firstname: string;
lastname: string;
initials: string;
visual: string;
phones: string[];
mobiles: string[];
primaryEmail: string;
secondaryEmail: string;
accessPIN: string;
inUse: string[];
entity: string;
};
export interface Contact {
name: string;
description: string;

1
src/models/General.ts Normal file
View File

@@ -0,0 +1 @@
export type AtLeast<T, K extends keyof T> = Partial<T> & Pick<T, K>;

View File

@@ -36,11 +36,7 @@ export interface _LoginFormProps {
setActiveForm: React.Dispatch<React.SetStateAction<LoginFormProps>>;
}
const _LoginForm = (
{
setActiveForm
}: _LoginFormProps
) => {
const _LoginForm: React.FC<_LoginFormProps> = ({ setActiveForm }) => {
const { t } = useTranslation();
const { setToken } = useAuth();
const { accessPolicyLink, passwordPolicyLink } = useApiRequirements();
@@ -50,9 +46,12 @@ const _LoginForm = (
const forgotPassword = () => setActiveForm({ form: 'forgot-password' });
const displayError = useMemo(() => {
const loginError = error as AxiosError;
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

@@ -87,6 +87,12 @@ const ServiceClassTable = ({ operatorId, refreshId, actions }) => {
columns={columns}
data={serviceClasses ?? []}
obj={t('service.other')}
sortBy={[
{
id: 'name',
desc: false,
},
]}
isLoading={isFetching}
minHeight="200px"
/>

View File

@@ -111,6 +111,12 @@ const OperatorsTable = () => {
isManual
hiddenColumns={hiddenColumns}
obj={t('operator.other')}
sortBy={[
{
id: 'name',
desc: false,
},
]}
count={count || 0}
// @ts-ignore
setPageInfo={setPageInfo}

View File

@@ -72,6 +72,7 @@ const SystemTile = ({ endpoint, token }: Props) => {
<SystemLoggingButton endpoint={endpoint} token={token} />
<Button
mt={1}
ml={2}
minWidth="112px"
colorScheme="gray"
rightIcon={<ArrowsClockwise />}

View File

@@ -5,22 +5,18 @@ import { useTranslation } from 'react-i18next';
import { v4 as uuid } from 'uuid';
import { useSendUserEmailValidation, useSuspendUser, useResetMfa, useResetPassword } from 'hooks/Network/Users';
import useMutationResult from 'hooks/useMutationResult';
import { AxiosError } from 'models/Axios';
interface Props {
id: string;
isSuspended: boolean;
isWaitingForCheck: boolean;
refresh: () => void;
isDisabled?: boolean;
size?: 'sm' | 'md' | 'lg';
}
const UserActions = (
{
id,
isSuspended,
isWaitingForCheck,
refresh
}: Props
) => {
const UserActions = ({ id, isSuspended, isWaitingForCheck, refresh, size = 'sm', isDisabled }: Props) => {
const { t } = useTranslation();
const toast = useToast();
const { mutateAsync: sendValidation } = useSendUserEmailValidation({ id, refresh });
@@ -38,7 +34,7 @@ const UserActions = (
onSuccess();
},
onError: (e) => {
onError(e);
onError(e as AxiosError);
},
});
const handleResetMfaClick = () =>
@@ -55,7 +51,7 @@ const UserActions = (
});
},
onError: (e) => {
onError(e);
onError(e as AxiosError);
},
});
@@ -73,7 +69,7 @@ const UserActions = (
});
},
onError: (e) => {
onError(e);
onError(e as AxiosError);
},
});
@@ -82,9 +78,16 @@ const UserActions = (
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

@@ -1,73 +1,83 @@
import React, { useEffect, useState } from 'react';
import { ExternalLinkIcon } from '@chakra-ui/icons';
import { Box, Flex, Link, useToast, Tabs, TabList, TabPanels, TabPanel, Tab, SimpleGrid } from '@chakra-ui/react';
import { Formik, Form } from 'formik';
import PropTypes from 'prop-types';
import axios from 'axios';
import { Formik, Form, FormikProps } from 'formik';
import { useTranslation } from 'react-i18next';
import { v4 as uuid } from 'uuid';
import NotesTable from 'components/CustomFields/NotesTable';
import * as Yup from 'yup';
import { NotesField } from 'components/FormFields/NotesField';
import SelectField from 'components/FormFields/SelectField';
import StringField from 'components/FormFields/StringField';
import ToggleField from 'components/FormFields/ToggleField';
import { UpdateUserSchema } from 'constants/formSchemas';
import { testObjectName, testRegex } from 'constants/formTests';
import { useAuth } from 'contexts/AuthProvider';
import { User, useUpdateUser } from 'hooks/Network/Users';
import useApiRequirements from 'hooks/useApiRequirements';
const propTypes = {
editing: PropTypes.bool.isRequired,
isOpen: PropTypes.bool.isRequired,
onClose: PropTypes.func.isRequired,
updateUser: PropTypes.instanceOf(Object).isRequired,
refreshUsers: PropTypes.func.isRequired,
userToUpdate: PropTypes.shape({
email: PropTypes.string.isRequired,
name: PropTypes.string.isRequired,
description: PropTypes.string.isRequired,
currentPassword: PropTypes.string.isRequired,
userRole: PropTypes.string.isRequired,
}).isRequired,
formRef: PropTypes.instanceOf(Object).isRequired,
type Props = {
editing: boolean;
isOpen: boolean;
onClose: () => void;
selectedUser: User;
formRef: React.Ref<FormikProps<User>>;
};
const UpdateUserForm = ({ editing, isOpen, onClose, updateUser, refreshUsers, userToUpdate, formRef }) => {
const UpdateUserForm = ({ editing, isOpen, onClose, selectedUser, formRef }: Props) => {
const { t } = useTranslation();
const toast = useToast();
const { user } = useAuth();
const [formKey, setFormKey] = useState(uuid());
const { passwordPolicyLink, passwordPattern } = useApiRequirements();
const updateUser = useUpdateUser();
const UpdateUserSchema = () =>
Yup.object().shape({
name: Yup.string().required(t('form.required')).test('len', t('common.name_error'), testObjectName),
currentPassword: Yup.string()
.notRequired()
.test('test-password', t('form.invalid_password'), (v) => testRegex(v, passwordPattern)),
description: Yup.string(),
mfa: Yup.string(),
phoneNumber: Yup.string(),
});
const formIsDisabled = () => {
if (!editing) return true;
if (user?.userRole === 'root') return false;
if (user?.userRole === 'partner') return false;
if (user?.userRole === 'admin') {
if (userToUpdate.userRole === 'partner' || userToUpdate.userRole === 'admin') return true;
if (selectedUser.userRole === 'root' || selectedUser.userRole === 'partner' || selectedUser.userRole === 'admin')
return true;
return false;
}
return true;
};
const canEditRole = () => {
if (selectedUser.userRole === 'root') return false;
if (user?.userRole === 'root') return true;
if (user?.userRole === 'admin' && selectedUser.userRole !== 'admin') return true;
return false;
};
useEffect(() => {
setFormKey(uuid());
}, [isOpen]);
}, [isOpen, editing]);
return (
<Formik
innerRef={formRef}
enableReinitialize
key={formKey}
initialValues={userToUpdate}
validationSchema={UpdateUserSchema(t, { passRegex: passwordPattern })}
onSubmit={(
{ name, description, currentPassword, userRole, notes, changePassword },
{ setSubmitting, resetForm },
) =>
initialValues={selectedUser}
validationSchema={UpdateUserSchema}
onSubmit={({ name, description, currentPassword, userRole, notes }, { setSubmitting, resetForm }) =>
updateUser.mutateAsync(
{
id: selectedUser.id,
name,
currentPassword: currentPassword.length > 0 ? currentPassword : undefined,
userRole,
changePassword,
description,
notes: notes.filter((note) => note.isNew),
},
@@ -86,23 +96,20 @@ const UpdateUserForm = ({ editing, isOpen, onClose, updateUser, refreshUsers, us
isClosable: true,
position: 'top-right',
});
refreshUsers();
onClose();
},
onError: (e) => {
toast({
id: uuid(),
title: t('common.error'),
description: t('crud.error_update_obj', {
obj: t('user.title'),
e: e?.response?.data?.ErrorDescription,
}),
status: 'error',
duration: 5000,
isClosable: true,
position: 'top-right',
});
setSubmitting(false);
if (axios.isAxiosError(e))
toast({
id: uuid(),
title: t('common.error'),
description: e?.response?.data?.ErrorDescription,
status: 'error',
duration: 5000,
isClosable: true,
position: 'top-right',
});
},
},
)
@@ -132,10 +139,9 @@ const UpdateUserForm = ({ editing, isOpen, onClose, updateUser, refreshUsers, us
{ value: 'system', label: 'System' },
]}
isRequired
isDisabled
isDisabled={!canEditRole() || formIsDisabled()}
/>
<StringField name="name" label={t('common.name')} isDisabled={formIsDisabled()} isRequired />
<ToggleField name="changePassword" label={t('users.change_password')} isDisabled={formIsDisabled()} />
<StringField
name="currentPassword"
label={t('user.password')}
@@ -147,8 +153,7 @@ const UpdateUserForm = ({ editing, isOpen, onClose, updateUser, refreshUsers, us
</Form>
</TabPanel>
<TabPanel>
{' '}
<NotesTable name="notes" isDisabled={!editing} />
<NotesField isDisabled={!editing} />
</TabPanel>
</TabPanels>
</Tabs>
@@ -165,6 +170,4 @@ const UpdateUserForm = ({ editing, isOpen, onClose, updateUser, refreshUsers, us
);
};
UpdateUserForm.propTypes = propTypes;
export default UpdateUserForm;

View File

@@ -1,111 +0,0 @@
import React, { useEffect } from 'react';
import {
Modal,
ModalOverlay,
ModalContent,
ModalBody,
useToast,
Spinner,
Center,
useDisclosure,
useBoolean,
} from '@chakra-ui/react';
import { useMutation } from '@tanstack/react-query';
import PropTypes from 'prop-types';
import { useTranslation } from 'react-i18next';
import UpdateUserForm from './Form';
import CloseButton from 'components/Buttons/CloseButton';
import EditButton from 'components/Buttons/EditButton';
import SaveButton from 'components/Buttons/SaveButton';
import ConfirmCloseAlert from 'components/Modals/Actions/ConfirmCloseAlert';
import ModalHeader from 'components/Modals/ModalHeader';
import { useGetUser } from 'hooks/Network/Users';
import useFormRef from 'hooks/useFormRef';
import { axiosSec } from 'utils/axiosInstances';
const propTypes = {
isOpen: PropTypes.bool.isRequired,
onClose: PropTypes.func.isRequired,
userId: PropTypes.string,
requirements: PropTypes.shape({
accessPolicy: PropTypes.string,
passwordPolicy: PropTypes.string,
}),
refreshUsers: PropTypes.func.isRequired,
};
const defaultProps = {
userId: '',
requirements: {
accessPolicy: '',
passwordPolicy: '',
},
};
const EditUserModal = ({ isOpen, onClose, userId, requirements, refreshUsers }) => {
const { t } = useTranslation();
const [editing, setEditing] = useBoolean();
const { isOpen: showConfirm, onOpen: openConfirm, onClose: closeConfirm } = useDisclosure();
const toast = useToast();
const { form, formRef } = useFormRef();
const canFetchUser = userId !== '' && isOpen;
const { data: user, isLoading } = useGetUser({ t, toast, id: userId, enabled: canFetchUser });
const createUser = useMutation((userInfo) => axiosSec.put(`user/${userId}`, userInfo));
const closeModal = () => (form.dirty ? openConfirm() : onClose());
const closeCancelAndForm = () => {
closeConfirm();
onClose();
};
useEffect(() => {
if (isOpen) setEditing.off();
}, [isOpen]);
return (
<Modal onClose={closeModal} isOpen={isOpen} size="xl" scrollBehavior="inside">
<ModalOverlay />
<ModalContent maxWidth={{ sm: '600px', md: '700px', lg: '800px', xl: '50%' }}>
<ModalHeader
title={t('crud.edit_obj', { obj: t('user.title') })}
right={
<>
<SaveButton
onClick={form.submitForm}
isLoading={form.isSubmitting}
isDisabled={!editing || !form.isValid || !form.dirty}
/>
<EditButton ml={2} isDisabled={editing} onClick={setEditing.toggle} isCompact />
<CloseButton ml={2} onClick={closeModal} />
</>
}
/>
<ModalBody>
{!isLoading && user ? (
<UpdateUserForm
editing={editing}
userToUpdate={user}
requirements={requirements}
updateUser={createUser}
isOpen={isOpen}
onClose={onClose}
refreshUsers={refreshUsers}
formRef={formRef}
/>
) : (
<Center>
<Spinner />
</Center>
)}
</ModalBody>
</ModalContent>
<ConfirmCloseAlert isOpen={showConfirm} confirm={closeCancelAndForm} cancel={closeConfirm} />
</Modal>
);
};
EditUserModal.propTypes = propTypes;
EditUserModal.defaultProps = defaultProps;
export default EditUserModal;

View File

@@ -0,0 +1,100 @@
import React, { useEffect } from 'react';
import { Spinner, Center, useDisclosure, useBoolean, Tag } from '@chakra-ui/react';
import { useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import ActionsDropdown from '../ActionsDropdown';
import UpdateUserForm from './Form';
import SaveButton from 'components/Buttons/SaveButton';
import ToggleEditButton from 'components/Buttons/ToggleEditButton';
import ConfirmCloseAlert from 'components/Modals/Actions/ConfirmCloseAlert';
import { Modal } from 'components/Modals/Modal';
import { User, useGetUser } from 'hooks/Network/Users';
import useFormRef from 'hooks/useFormRef';
type Props = {
userId?: string;
isOpen: boolean;
onClose: () => void;
};
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, refetch } = useGetUser({ id: userId ?? '', enabled: canFetchUser });
const closeModal = () => (form.dirty ? openConfirm() : onClose());
const closeCancelAndForm = () => {
closeConfirm();
onClose();
};
const refresh = () => {
refetch();
queryClient.invalidateQueries(['users']);
};
useEffect(() => {
if (isOpen) setEditing.off();
}, [isOpen]);
return (
<>
<Modal
isOpen={isOpen}
onClose={closeModal}
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}
/>
<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}
</>
}
>
{!isFetching && user ? (
<UpdateUserForm editing={editing} selectedUser={user} isOpen={isOpen} onClose={onClose} formRef={formRef} />
) : (
<Center>
<Spinner />
</Center>
)}
</Modal>
<ConfirmCloseAlert isOpen={showConfirm} confirm={closeCancelAndForm} cancel={closeConfirm} />
</>
);
};
export default EditUserModal;

View File

@@ -164,6 +164,7 @@ const UserTable = ({ title }) => {
data={showUsers()}
isLoading={isFetching}
obj={t('users.title')}
sortBy={[{ id: 'email', desc: false }]}
hiddenColumns={hiddenColumns}
fullScreen
/>