[WIFI-11543] Added API keys to profile page

Signed-off-by: Charles <charles.bourque96@gmail.com>
This commit is contained in:
Charles
2022-11-11 12:49:24 +00:00
parent b2553d35fd
commit 14347b73f2
18 changed files with 954 additions and 9 deletions

29
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "ucentral-client",
"version": "2.8.0(10)",
"version": "2.8.0(11)",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "ucentral-client",
"version": "2.8.0(10)",
"version": "2.8.0(11)",
"license": "ISC",
"dependencies": {
"@chakra-ui/icons": "^2.0.11",
@@ -57,6 +57,7 @@
"@types/node": "^18.11.2",
"@types/react": "^18.0.21",
"@types/react-csv": "^1.1.3",
"@types/react-datepicker": "4.8.0",
"@types/react-dom": "^18.0.6",
"@types/react-table": "^7.7.12",
"@types/react-virtualized-auto-sizer": "^1.0.1",
@@ -3551,6 +3552,18 @@
"@types/react": "*"
}
},
"node_modules/@types/react-datepicker": {
"version": "4.8.0",
"resolved": "https://registry.npmjs.org/@types/react-datepicker/-/react-datepicker-4.8.0.tgz",
"integrity": "sha512-20uzZsIf4moPAjjHDfPvH8UaOHZBxrkiQZoLS3wgKq8Xhp+95gdercLEdoA7/I8nR9R5Jz2qQkdMIM+Lq4AS1A==",
"dev": true,
"dependencies": {
"@popperjs/core": "^2.9.2",
"@types/react": "*",
"date-fns": "^2.0.1",
"react-popper": "^2.2.5"
}
},
"node_modules/@types/react-dom": {
"version": "18.0.6",
"dev": true,
@@ -11815,6 +11828,18 @@
"@types/react": "*"
}
},
"@types/react-datepicker": {
"version": "4.8.0",
"resolved": "https://registry.npmjs.org/@types/react-datepicker/-/react-datepicker-4.8.0.tgz",
"integrity": "sha512-20uzZsIf4moPAjjHDfPvH8UaOHZBxrkiQZoLS3wgKq8Xhp+95gdercLEdoA7/I8nR9R5Jz2qQkdMIM+Lq4AS1A==",
"dev": true,
"requires": {
"@popperjs/core": "^2.9.2",
"@types/react": "*",
"date-fns": "^2.0.1",
"react-popper": "^2.2.5"
}
},
"@types/react-dom": {
"version": "18.0.6",
"dev": true,

View File

@@ -1,6 +1,6 @@
{
"name": "ucentral-client",
"version": "2.8.0(10)",
"version": "2.8.0(11)",
"description": "",
"private": true,
"main": "index.tsx",
@@ -65,6 +65,7 @@
"@types/react-csv": "^1.1.3",
"@types/react-dom": "^18.0.6",
"@types/react-table": "^7.7.12",
"@types/react-datepicker": "4.8.0",
"@types/uuid": "^8.3.4",
"@types/react-virtualized-auto-sizer": "^1.0.1",
"@types/react-window": "^1.8.5",

View File

@@ -245,6 +245,7 @@
"identification": "Identifizierung",
"inherit": "Erben",
"language": "Sprache",
"last_use": "Zuletzt verwendeten",
"lifetime": "Lebenszeit",
"locale": "Gebietsschema",
"logout": "Ausloggen",
@@ -261,6 +262,7 @@
"model": "Modell",
"modified": "Geändert",
"monthly": "Monatlich",
"months": "Monate",
"my_account": "Mein Konto",
"name": "Name",
"name_error": "Der Name muss weniger als 50 Zeichen lang sein",
@@ -314,6 +316,7 @@
"use_file": "Datei verwenden",
"value": "Wert",
"variable": "Variable",
"view": "Aussicht",
"view_details": "Details anzeigen",
"view_in_gateway": "Im Controller anzeigen",
"view_json": "JSON anzeigen",
@@ -742,6 +745,15 @@
"successful_macs": "Erfolgreiche MACs",
"title": "Arbeitsplätze"
},
"keys": {
"description_error": "Die Beschreibung muss weniger als 64 Zeichen lang sein",
"expire_error": "Der Ablauf darf nicht mehr als ein Jahr in der Zukunft liegen",
"expires": "Läuft ab",
"max_keys": "Max. Schlüssel erreicht (10)",
"name_error": "Der Name sollte eindeutig sein und aus 6 bis 20 alphanumerischen Zeichen bestehen",
"one": "API-Schlüssel",
"other": "API-Schlüssel"
},
"locations": {
"address_line_one": "Adresszeile eins",
"address_line_two": "Adresszeile zwei",
@@ -802,7 +814,10 @@
"receiving_types": "Typen empfangen",
"security": "Sicherheit",
"source": "Quelle",
"thread": "Faden"
"thread": "Faden",
"venue_config": "Aufbau",
"venue_reboot": "Starten Sie neu",
"venue_upgrade": "Aktualisierung"
},
"map": {
"auto_align": "Automatisch ausrichten",
@@ -838,6 +853,22 @@
"my_organization": "Meine Organisation",
"title": "Organisation"
},
"overrides": {
"delete_source": "Alle Überschreibungen von {{source}}löschen",
"ignore_overrides": "Konfigurationsüberschreibungen ignorieren",
"name_error": "Der Parameter ist bereits von Ihrer Quelle definiert",
"one": "Konfigurationsüberschreibung",
"other": "Konfigurationsüberschreibungen",
"param_name": "Parameter",
"param_value": "Wert",
"parameter": "Parameter",
"reason": "Grund",
"reason_error": "Ihr Grund muss weniger als 64 Zeichen lang sein. lang",
"source": "Quelle",
"tx_power_error": "Die Sendeleistung muss zwischen 1 und 32 liegen",
"update_success": "Aktualisierte Konfigurationsüberschreibungen!",
"value": "Wert"
},
"profile": {
"about_me": "Über mich",
"activate": "",

View File

@@ -245,6 +245,7 @@
"identification": "Identification",
"inherit": "Inherit",
"language": "Language",
"last_use": "Last Use",
"lifetime": "Lifetime",
"locale": "Locale",
"logout": "Logout",
@@ -261,6 +262,7 @@
"model": "Model",
"modified": "Modified",
"monthly": "Monthly",
"months": "Months",
"my_account": "My Account",
"name": "Name",
"name_error": "Name must be less than 50 characters long",
@@ -314,6 +316,7 @@
"use_file": "Use File",
"value": "Value",
"variable": "Variable",
"view": "View",
"view_details": "View Details",
"view_in_gateway": "View In Controller",
"view_json": "View JSON",
@@ -742,6 +745,15 @@
"successful_macs": "Successful MACs",
"title": "Jobs"
},
"keys": {
"description_error": "Description needs to be less than 64 characters long",
"expire_error": "The expiry cannot be more than one year in the future",
"expires": "Expires",
"max_keys": "Max keys reached (10)",
"name_error": "Name should be unique and be between 6 and 20 alphanumeric characters",
"one": "API Key",
"other": "API Keys"
},
"locations": {
"address_line_one": "Address Line One",
"address_line_two": "Address Line Two",
@@ -802,7 +814,10 @@
"receiving_types": "Receiving Types",
"security": "Security",
"source": "Source",
"thread": "Thread"
"thread": "Thread",
"venue_config": "Configuration",
"venue_reboot": "Reboot",
"venue_upgrade": "Upgrade"
},
"map": {
"auto_align": "Auto Align",
@@ -838,6 +853,22 @@
"my_organization": "My Organization",
"title": "Organization"
},
"overrides": {
"delete_source": "Delete all overrides from {{source}}",
"ignore_overrides": "Ignore Configuration Overrides",
"name_error": "Parameter is already defined by your source",
"one": "Configuration Override",
"other": "Configuration Overrides",
"param_name": "Parameter",
"param_value": "Value",
"parameter": "Parameter",
"reason": "Reason",
"reason_error": "Your reason needs to be less than 64 chars. long",
"source": "Source",
"tx_power_error": "Tx power needs to be between 1 and 32",
"update_success": "Updated Configuration Overrides!",
"value": "Value"
},
"profile": {
"about_me": "About Me",
"activate": "Activate",

View File

@@ -245,6 +245,7 @@
"identification": "identificación",
"inherit": "Heredar",
"language": "idioma",
"last_use": "Utilizado por última vez",
"lifetime": "Toda la vida",
"locale": "lugar",
"logout": "Cerrar sesión",
@@ -261,6 +262,7 @@
"model": "Modelo",
"modified": "Modificado",
"monthly": "Mensual",
"months": "Meses",
"my_account": "Mi cuenta",
"name": "Nombre",
"name_error": "El nombre debe tener menos de 50 caracteres",
@@ -314,6 +316,7 @@
"use_file": "Usar archivo",
"value": "Valor",
"variable": "Variable",
"view": "Ver",
"view_details": "Ver detalles",
"view_in_gateway": "Ver en controlador",
"view_json": "Ver JSON",
@@ -742,6 +745,15 @@
"successful_macs": "MAC exitosos",
"title": "Trabajos"
},
"keys": {
"description_error": "La descripción debe tener menos de 64 caracteres",
"expire_error": "El vencimiento no puede ser más de un año en el futuro",
"expires": "Vence",
"max_keys": "Número máximo de claves alcanzado (10)",
"name_error": "El nombre debe ser único y tener entre 6 y 20 caracteres alfanuméricos",
"one": "Clave API",
"other": "Claves de api"
},
"locations": {
"address_line_one": "Dirección Línea Uno",
"address_line_two": "Dirección línea dos",
@@ -802,7 +814,10 @@
"receiving_types": "Tipos de recepción",
"security": "SEGURIDAD",
"source": "Fuente",
"thread": "Hilo"
"thread": "Hilo",
"venue_config": "Configuración",
"venue_reboot": "Reiniciar",
"venue_upgrade": "Mejorar"
},
"map": {
"auto_align": "Alineación automática",
@@ -838,6 +853,22 @@
"my_organization": "MI ORGANIZACION",
"title": "Organización"
},
"overrides": {
"delete_source": "Eliminar todas las anulaciones de {{source}}",
"ignore_overrides": "Ignorar anulaciones de configuración",
"name_error": "El parámetro ya está definido por su fuente",
"one": "Anulación de configuración",
"other": "Anulaciones de configuración",
"param_name": "parámetro",
"param_value": "Valor",
"parameter": "parámetro",
"reason": "Razón",
"reason_error": "Su motivo debe tener menos de 64 caracteres. largo",
"source": "Fuente",
"tx_power_error": "La potencia Tx debe estar entre 1 y 32",
"update_success": "¡Anulaciones de configuración actualizadas!",
"value": "Valor"
},
"profile": {
"about_me": "Sobre mí",
"activate": "",

View File

@@ -245,6 +245,7 @@
"identification": "Identification",
"inherit": "Hériter",
"language": "La langue",
"last_use": "Dernière utilisation",
"lifetime": "durée de vie",
"locale": "lieu",
"logout": "Connectez - Out",
@@ -261,6 +262,7 @@
"model": "Modèle",
"modified": "Modifié",
"monthly": "Mensuel",
"months": "mois",
"my_account": "Mon compte",
"name": "Prénom",
"name_error": "Le nom doit comporter moins de 50 caractères",
@@ -314,6 +316,7 @@
"use_file": "Utiliser le fichier",
"value": "Valeur",
"variable": "Variable",
"view": "Vue",
"view_details": "Voir les détails",
"view_in_gateway": "Afficher dans le contrôleur",
"view_json": "Afficher JSON",
@@ -742,6 +745,15 @@
"successful_macs": "MAC réussis",
"title": "Emplois"
},
"keys": {
"description_error": "La description doit comporter moins de 64 caractères",
"expire_error": "L'expiration ne peut pas être supérieure à un an dans le futur",
"expires": "EXPIRÉ",
"max_keys": "Max de clés atteint (10)",
"name_error": "Le nom doit être unique et comporter entre 6 et 20 caractères alphanumériques",
"one": "Clé API",
"other": "Clés API"
},
"locations": {
"address_line_one": "Adresse Ligne 1",
"address_line_two": "Adresse ligne deux",
@@ -802,7 +814,10 @@
"receiving_types": "Types de réception",
"security": "SÉCURITÉ",
"source": "La source",
"thread": "Fil de discussion"
"thread": "Fil de discussion",
"venue_config": "Configuration",
"venue_reboot": "Redémarrer",
"venue_upgrade": "Améliorer"
},
"map": {
"auto_align": "Alignement automatique",
@@ -838,6 +853,22 @@
"my_organization": "Mon organisation",
"title": "Organisation"
},
"overrides": {
"delete_source": "Supprimer tous les remplacements de {{source}}",
"ignore_overrides": "Ignorer les remplacements de configuration",
"name_error": "Le paramètre est déjà défini par votre source",
"one": "Remplacement de la configuration",
"other": "Remplacements de configuration",
"param_name": "paramètre",
"param_value": "Valeur",
"parameter": "paramètre",
"reason": "raison",
"reason_error": "Votre raison doit être inférieure à 64 caractères. long",
"source": "La source",
"tx_power_error": "La puissance de transmission doit être comprise entre 1 et 32",
"update_success": "Remplacements de configuration mis à jour !",
"value": "Valeur"
},
"profile": {
"about_me": "À propos de moi",
"activate": "",

View File

@@ -245,6 +245,7 @@
"identification": "Identificação",
"inherit": "Herdar",
"language": "Língua",
"last_use": "Usado por último",
"lifetime": "Tempo de vida",
"locale": "Localidade",
"logout": "Sair",
@@ -261,6 +262,7 @@
"model": "Modelo",
"modified": "Modificado",
"monthly": "Por mês",
"months": "Meses",
"my_account": "Minha conta",
"name": "Nome",
"name_error": "O nome deve ter menos de 50 caracteres",
@@ -314,6 +316,7 @@
"use_file": "Usar arquivo",
"value": "Valor",
"variable": "Variável",
"view": "Visão",
"view_details": "VER DETALHES",
"view_in_gateway": "Ver no controlador",
"view_json": "Ver JSON",
@@ -742,6 +745,15 @@
"successful_macs": "MACs de sucesso",
"title": "Empregos"
},
"keys": {
"description_error": "A descrição precisa ter menos de 64 caracteres",
"expire_error": "A expiração não pode ser superior a um ano no futuro",
"expires": "expira",
"max_keys": "Teclas máximas alcançadas (10)",
"name_error": "O nome deve ser único e ter entre 6 e 20 caracteres alfanuméricos",
"one": "Chave API",
"other": "Chaves de Api"
},
"locations": {
"address_line_one": "Linha de endereço um",
"address_line_two": "Linha de endereço dois",
@@ -802,7 +814,10 @@
"receiving_types": "Tipos de recebimento",
"security": "SEGURANÇA",
"source": "Fonte",
"thread": "FIO"
"thread": "FIO",
"venue_config": "Configuração",
"venue_reboot": "Reiniciar",
"venue_upgrade": "Melhorar"
},
"map": {
"auto_align": "Alinhamento Automático",
@@ -838,6 +853,22 @@
"my_organization": "Minha organização",
"title": "Organização"
},
"overrides": {
"delete_source": "Excluir todas as substituições de {{source}}",
"ignore_overrides": "Ignorar substituições de configuração",
"name_error": "O parâmetro já está definido pela sua fonte",
"one": "Substituição de configuração",
"other": "Substituições de configuração",
"param_name": "parâmetro",
"param_value": "Valor",
"parameter": "parâmetro",
"reason": "RAZÃO",
"reason_error": "Seu motivo precisa ter menos de 64 caracteres. grandes",
"source": "Fonte",
"tx_power_error": "A potência Tx precisa estar entre 1 e 32",
"update_success": "Substituições de configuração atualizadas!",
"value": "Valor"
},
"profile": {
"about_me": "Sobre mim",
"activate": "",

View File

@@ -0,0 +1,15 @@
import React, { forwardRef } from 'react';
import { Button } from '@chakra-ui/react';
type Props = {
value?: string;
onClick?: () => void;
isDisabled?: boolean;
};
const DatePickerInput = forwardRef(({ value, onClick, isDisabled }: Props, ref: React.Ref<HTMLButtonElement>) => (
<Button colorScheme="gray" onClick={onClick} ref={ref} isDisabled={isDisabled}>
{value}
</Button>
));
export default DatePickerInput;

View File

@@ -0,0 +1,37 @@
import React from 'react';
import DatePicker from 'react-datepicker';
import { useTranslation } from 'react-i18next';
import 'react-datepicker/dist/react-datepicker.css';
import DatePickerInput from '../DatePickerInput';
type Props = {
date: Date;
onChange: (v: Date | null) => void;
isStart?: boolean;
isEnd?: boolean;
startDate?: Date;
endDate?: Date;
isDisabled?: boolean;
};
const DateTimePicker = ({ date, onChange, isStart, isEnd, startDate, endDate, isDisabled }: Props) => {
const { t } = useTranslation();
return (
<DatePicker
selected={date}
onChange={onChange}
selectsStart={isStart}
selectsEnd={isEnd}
startDate={startDate}
endDate={endDate}
timeInputLabel={`${t('common.time')}: `}
dateFormat="dd/MM/yyyy hh:mm aa"
timeFormat="p"
showTimeSelect
customInput={<DatePickerInput isDisabled={isDisabled} />}
/>
);
};
export default React.memo(DateTimePicker);

View File

@@ -0,0 +1,84 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { axiosSec } from 'constants/axiosInstances';
import { AxiosError } from 'models/Axios';
export type ApiKey = {
id: string;
userUuid: string;
name: string;
description: string;
apiKey: string;
expiresOn: number;
lastUse: number;
};
export type ApiKeyResponse = {
apiKeys: ApiKey[];
};
const getKeys = async (uuid?: string) =>
axiosSec
.get(`apiKey/${uuid}`)
.then(({ data }: { data: ApiKeyResponse }) => data)
.catch((e: AxiosError) => {
if (e.response?.status === 404) {
return {
apiKeys: [],
} as ApiKeyResponse;
}
throw e;
});
export const useGetUserApiKeys = ({ userId }: { userId?: string }) =>
useQuery(['apiKeys', userId], () => getKeys(userId), {
enabled: userId !== undefined && userId !== '',
staleTime: 1000 * 60 * 30,
});
const deleteKey = async ({ userId, keyId }: { userId: string; keyId: string }) =>
axiosSec.delete(`apiKey/${userId}?keyUuid=${keyId}`, {});
export const useDeleteApiKey = ({ userId }: { userId?: string }) => {
const queryClient = useQueryClient();
return useMutation(deleteKey, {
onSuccess: () => {
if (userId !== undefined && userId.length > 0) {
queryClient.invalidateQueries(['apiKeys', userId]);
}
},
});
};
const updateKey = async ({ description, userId, keyId }: { description: string; userId: string; keyId: string }) =>
axiosSec.put(`apiKey/${userId}?keyUuid=${keyId}`, { id: keyId, userUuid: userId, description });
export const useUpdateApiKey = ({ userId }: { userId?: string }) => {
const queryClient = useQueryClient();
return useMutation(updateKey, {
onSuccess: () => {
if (userId !== undefined && userId.length > 0) {
queryClient.invalidateQueries(['apiKeys', userId]);
}
},
});
};
const createKey = async ({
data,
userId,
}: {
userId: string;
data: { userUuid: string; name: string; description: string; expiresOn: number };
}) => axiosSec.post(`apiKey/${userId}`, data);
export const useCreateApiKey = ({ userId }: { userId?: string }) => {
const queryClient = useQueryClient();
return useMutation(createKey, {
onSuccess: () => {
if (userId !== undefined && userId.length > 0) {
queryClient.invalidateQueries(['apiKeys', userId]);
}
},
});
};

View File

@@ -0,0 +1,140 @@
import React from 'react';
import { CopyIcon } from '@chakra-ui/icons';
import {
IconButton,
Tooltip,
Popover,
PopoverArrow,
PopoverBody,
PopoverCloseButton,
PopoverContent,
PopoverFooter,
PopoverHeader,
PopoverTrigger,
Center,
Box,
Button,
useDisclosure,
HStack,
Text,
useClipboard,
} from '@chakra-ui/react';
import { Eye, Trash } from 'phosphor-react';
import { useTranslation } from 'react-i18next';
import { ApiKey, useDeleteApiKey } from 'hooks/Network/ApiKeys';
interface Props {
apiKey: ApiKey;
isDisabled?: boolean;
}
const ApiKeyActions = ({ apiKey, isDisabled }: Props) => {
const { t } = useTranslation();
const { isOpen, onOpen, onClose } = useDisclosure();
const deleteKey = useDeleteApiKey({ userId: apiKey.userUuid });
const { hasCopied, onCopy } = useClipboard(apiKey.apiKey);
const handleDeleteClick = React.useCallback(() => {
deleteKey.mutate(
{ userId: apiKey.userUuid, keyId: apiKey.id },
{
onSuccess: () => {
onClose();
},
},
);
}, []);
return (
<HStack mx="auto">
<Popover isOpen={isOpen} onOpen={onOpen} onClose={onClose}>
<Tooltip hasArrow label={t('crud.delete')} placement="top" isDisabled={isOpen}>
<Box>
<PopoverTrigger>
<IconButton
aria-label="delete-device"
colorScheme="red"
icon={<Trash size={20} />}
size="sm"
isDisabled={isDisabled}
/>
</PopoverTrigger>
</Box>
</Tooltip>
<PopoverContent w="340px">
<PopoverArrow />
<PopoverCloseButton />
<PopoverHeader>
{t('crud.delete')} {apiKey.name}
</PopoverHeader>
<PopoverBody>
<Text whiteSpace="break-spaces">{t('crud.delete_confirm', { obj: t('keys.one') })}</Text>
</PopoverBody>
<PopoverFooter>
<Center>
<Button colorScheme="gray" mr="1" onClick={onClose}>
{t('common.cancel')}
</Button>
<Button colorScheme="red" ml="1" onClick={handleDeleteClick} isLoading={deleteKey.isLoading}>
{t('common.yes')}
</Button>
</Center>
</PopoverFooter>
</PopoverContent>
</Popover>
<Tooltip
label={hasCopied ? `${t('common.copied')}!` : `${t('common.copy')} ${t('keys.one')}`}
hasArrow
closeOnClick={false}
>
<IconButton
aria-label={t('common.copy')}
icon={<CopyIcon h={5} w={5} />}
onClick={onCopy}
size="sm"
colorScheme="teal"
mr={2}
/>
</Tooltip>
<Popover>
<Tooltip label={`${t('common.view')} ${t('keys.one')}`} hasArrow closeOnClick={false}>
<Box>
<PopoverTrigger>
<IconButton aria-label={t('common.view')} icon={<Eye size={20} />} size="sm" colorScheme="purple" />
</PopoverTrigger>
</Box>
</Tooltip>
<PopoverContent w="560px">
<PopoverArrow />
<PopoverCloseButton />
<PopoverHeader>
{t('common.view')} {apiKey.name} {t('keys.one')}
<Tooltip
label={hasCopied ? `${t('common.copied')}!` : `${t('common.copy')} ${t('keys.one')}`}
hasArrow
closeOnClick={false}
>
<IconButton
aria-label={t('common.copy')}
icon={<CopyIcon h={4} w={4} />}
onClick={onCopy}
size="xs"
colorScheme="teal"
ml={2}
/>
</Tooltip>
</PopoverHeader>
<PopoverBody>
<Text whiteSpace="break-spaces">
<Center>
<pre style={{ fontFamily: 'monospace' }}>{apiKey.apiKey}</pre>
</Center>
</Text>
</PopoverBody>
</PopoverContent>
</Popover>
</HStack>
);
};
export default ApiKeyActions;

View File

@@ -0,0 +1,131 @@
import * as React from 'react';
import {
Alert,
AlertDescription,
AlertIcon,
Box,
Button,
FormControl,
FormErrorMessage,
FormLabel,
Input,
Textarea,
useDisclosure,
useToast,
} from '@chakra-ui/react';
import axios from 'axios';
import { useTranslation } from 'react-i18next';
import ApiKeyExpiresOnField from './ExpiresOnField';
import { CreateButton } from 'components/Buttons/CreateButton';
import { Modal } from 'components/Modals/Modal';
import { ApiKey, useCreateApiKey } from 'hooks/Network/ApiKeys';
type Props = {
apiKeys: ApiKey[];
userId: string;
isDisabled?: boolean;
};
const thirtyDaysFromNow = () => Math.floor(Date.now() / 1000) + 60 * 60 * 24 * 30;
const CreateApiKeyButton = ({ apiKeys, userId, isDisabled }: Props) => {
const { t } = useTranslation();
const toast = useToast();
const { isOpen, onOpen, onClose } = useDisclosure();
const [name, setName] = React.useState('');
const [description, setDescription] = React.useState('');
const [expiresOn, setExpiresOn] = React.useState(thirtyDaysFromNow());
const createKey = useCreateApiKey({ userId });
const onNameChange = (e: React.ChangeEvent<HTMLInputElement>) => setName(e.target.value);
const onDescriptionChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => setDescription(e.target.value);
const onCreate = React.useCallback(() => {
createKey.mutate(
{ data: { name, description, userUuid: userId, expiresOn }, userId },
{
onSuccess: () => {
onClose();
},
onError: (e) => {
if (axios.isAxiosError(e)) {
toast({
id: 'create-api-key-error',
title: t('common.error'),
description: e?.response?.data?.ErrorDescription,
status: 'error',
duration: 5000,
isClosable: true,
position: 'top-right',
});
}
},
},
);
}, [name, description, expiresOn]);
const nameError = React.useMemo(() => {
if (
name.length === 0 ||
name.length > 20 ||
apiKeys.map(({ name: n }) => n).includes(name) ||
!/^[a-z0-9]+$/i.test(name)
) {
return t('keys.name_error');
}
return undefined;
}, [name, apiKeys]);
const handleOpenClick = () => {
setName('');
setDescription('');
setExpiresOn(thirtyDaysFromNow());
onOpen();
};
return (
<>
{apiKeys.length >= 10 && (
<Alert status="error">
<AlertIcon />
<AlertDescription>{t('keys.max_keys')}</AlertDescription>
</Alert>
)}
<CreateButton onClick={handleOpenClick} isDisabled={isDisabled || apiKeys.length >= 10} isCompact />
<Modal
isOpen={isOpen}
onClose={onClose}
title={` ${t('crud.create')} ${t('keys.one')}`}
topRightButtons={
<Button
colorScheme="blue"
ml="1"
onClick={onCreate}
isDisabled={nameError !== undefined || description.length > 64}
>
{t('common.save')}
</Button>
}
options={{
modalSize: 'sm',
}}
>
<Box>
<FormControl mb={2} isInvalid={nameError !== undefined}>
<FormLabel>{t('common.name')}</FormLabel>
<Input value={name} onChange={onNameChange} />
<FormErrorMessage>{nameError}</FormErrorMessage>
</FormControl>
<FormControl mb={2} isInvalid={description.length > 64}>
<FormLabel>{t('common.description')}</FormLabel>
<Textarea value={description} onChange={onDescriptionChange} noOfLines={2} />
<FormErrorMessage>{t('keys.description_error')}</FormErrorMessage>
</FormControl>
<ApiKeyExpiresOnField value={expiresOn} setValue={setExpiresOn} />
</Box>
</Modal>
</>
);
};
export default CreateApiKeyButton;

View File

@@ -0,0 +1,123 @@
import * as React from 'react';
import {
Box,
Button,
Center,
FormControl,
FormErrorMessage,
FormLabel,
IconButton,
Popover,
PopoverArrow,
PopoverBody,
PopoverCloseButton,
PopoverContent,
PopoverFooter,
PopoverHeader,
PopoverTrigger,
Text,
Textarea,
Tooltip,
useDisclosure,
useToast,
} from '@chakra-ui/react';
import axios from 'axios';
import { Pen } from 'phosphor-react';
import { useTranslation } from 'react-i18next';
import { ApiKey, useUpdateApiKey } from 'hooks/Network/ApiKeys';
type Props = {
apiKey: ApiKey;
};
const ApiKeyDescriptionCell = ({ apiKey }: Props) => {
const { t } = useTranslation();
const toast = useToast();
const [newDescription, setNewDescription] = React.useState(apiKey.description);
const { isOpen, onOpen, onClose } = useDisclosure();
const updateApiKey = useUpdateApiKey({ userId: apiKey.userUuid });
const onDescriptionChange = React.useCallback((e: React.ChangeEvent<HTMLTextAreaElement>) => {
setNewDescription(e.target.value);
}, []);
const handleSaveClick = () => {
updateApiKey.mutate(
{
userId: apiKey.userUuid,
keyId: apiKey.id,
description: newDescription,
},
{
onSuccess: () => {
onClose();
},
onError: (e) => {
if (axios.isAxiosError(e)) {
toast({
id: 'error-save-api-key-description',
title: t('common.error'),
description: e?.response?.data?.ErrorDescription,
status: 'error',
duration: 5000,
isClosable: true,
position: 'top-right',
});
}
},
},
);
};
const onCancel = () => {
setNewDescription(apiKey.description);
onClose();
};
return (
<Text w="100%" overflowWrap="break-word" whiteSpace="pre-wrap">
<Popover isOpen={isOpen} onOpen={onOpen} onClose={onClose}>
<Tooltip label={t('common.edit')} hasArrow closeOnClick={false} isDisabled={isOpen}>
<Box display="unset">
<PopoverTrigger>
<IconButton aria-label={t('common.edit')} icon={<Pen size={20} />} size="xs" colorScheme="teal" mr={2} />
</PopoverTrigger>
</Box>
</Tooltip>
<PopoverContent w="340px">
<PopoverArrow />
<PopoverCloseButton />
<PopoverHeader>
{t('common.edit')} {apiKey.name} {t('common.description')}
</PopoverHeader>
<PopoverBody>
<FormControl mb={2} isInvalid={newDescription.length > 64}>
<FormLabel>{t('common.description')}</FormLabel>
<Textarea value={newDescription} onChange={onDescriptionChange} noOfLines={2} />
<FormErrorMessage>{t('keys.description_error')}</FormErrorMessage>
</FormControl>
</PopoverBody>
<PopoverFooter>
<Center>
<Button colorScheme="gray" mr="1" onClick={onCancel}>
{t('common.cancel')}
</Button>
<Button
colorScheme="blue"
ml="1"
onClick={handleSaveClick}
isDisabled={newDescription.length > 64}
isLoading={updateApiKey.isLoading}
>
{t('common.save')}
</Button>
</Center>
</PopoverFooter>
</PopoverContent>
</Popover>
{apiKey.description}
</Text>
);
};
export default ApiKeyDescriptionCell;

View File

@@ -0,0 +1,81 @@
import * as React from 'react';
import { Box, Flex, FormControl, FormErrorMessage, FormLabel, Radio, RadioGroup, Stack, Text } from '@chakra-ui/react';
import { useTranslation } from 'react-i18next';
import DateTimePicker from 'components/DatePickers/DateTimePicker';
type Props = {
value: number;
setValue: (value: number) => void;
};
const aYearFromNow = () => Math.floor(Date.now() / 1000) + 60 * 60 * 24 * 365;
const ApiKeyExpiresOnField = ({ value, setValue }: Props) => {
const { t } = useTranslation();
const [radio, setRadioValue] = React.useState('30');
const onRadioChange = (v: string) => {
setRadioValue(v);
const days = parseInt(v, 10);
const now = new Date();
if (days > 0) {
now.setDate(now.getDate() + days);
setValue(Math.floor(now.getTime() / 1000));
} else {
now.setDate(now.getDate() + 30);
setValue(Math.floor(now.getTime() / 1000));
}
};
const onDateChange = (v: Date | null) => {
if (v) setValue(Math.floor(v.getTime() / 1000));
};
const tempDate = () => {
if (!value || value === 0) return new Date();
return new Date(value * 1000);
};
return (
<FormControl isInvalid={value > aYearFromNow()} isRequired>
<FormLabel ms={0} mb={2} fontSize="md" fontWeight="normal" _disabled={{ opacity: 0.8 }}>
{t('certificates.expires_on')}
</FormLabel>
<Box>
<RadioGroup onChange={onRadioChange} defaultValue="30">
<Stack spacing={5} direction="column">
<Radio colorScheme="blue" value="30">
30 {t('common.days')}
</Radio>
<Radio colorScheme="blue" value="60">
60 {t('common.days')}
</Radio>
<Radio colorScheme="blue" value="90">
90 {t('common.days')}
</Radio>
<Radio colorScheme="blue" value="180">
6 {t('common.months')}
</Radio>
<Radio colorScheme="green" value="0">
<Flex>
<Text my="auto" mr={2} w="180px">
{t('common.custom')}
</Text>
<DateTimePicker
date={tempDate()}
isStart
onChange={onDateChange}
isDisabled={!value || value === 0 || radio !== '0'}
/>
</Flex>
</Radio>
</Stack>
</RadioGroup>
</Box>
<FormErrorMessage>{t('keys.expire_error')}</FormErrorMessage>
</FormControl>
);
};
export default ApiKeyExpiresOnField;

View File

@@ -0,0 +1,54 @@
import * as React from 'react';
import { Box, Flex, Heading, HStack, Spacer } from '@chakra-ui/react';
import { useTranslation } from 'react-i18next';
import CreateApiKeyButton from './AddButton';
import useApiKeyTable from './useApiKeyTable';
import { RefreshButton } from 'components/Buttons/RefreshButton';
import { ColumnPicker } from 'components/DataTables/ColumnPicker';
import { DataTable } from 'components/DataTables/DataTable';
import { Column } from 'models/Table';
type Props = {
userId: string;
};
const ApiKeyTable = ({ userId }: Props) => {
const { t } = useTranslation();
const { query, columns, hiddenColumns } = useApiKeyTable({ userId });
return (
<Box>
<Flex mb={2}>
<Heading size="md" my="auto">
{t('keys.other')} ({query.data?.apiKeys.length})
</Heading>
<Spacer />
<HStack spacing={2}>
<CreateApiKeyButton userId={userId} apiKeys={query.data?.apiKeys ?? []} />
<ColumnPicker
columns={columns as Column<unknown>[]}
hiddenColumns={hiddenColumns[0]}
setHiddenColumns={hiddenColumns[1]}
preference="apiKeys.profile.table.hiddenColumns"
/>
<RefreshButton onClick={query.refetch} isFetching={query.isFetching} isCompact />
</HStack>
</Flex>
<Box>
<DataTable
columns={columns as Column<object>[]}
saveSettingsId="apiKeys.profile.table"
data={query.data?.apiKeys ?? []}
obj={t('keys.other')}
sortBy={[{ id: 'expiresOn', desc: false }]}
minHeight="400px"
hiddenColumns={hiddenColumns[0]}
showAllRows
hideControls
/>
</Box>
</Box>
);
};
export default ApiKeyTable;

View File

@@ -0,0 +1,78 @@
import * as React from 'react';
import { useTranslation } from 'react-i18next';
import ApiKeyActions from './Actions';
import ApiKeyDescriptionCell from './DescriptionCell';
import FormattedDate from 'components/InformationDisplays/FormattedDate';
import { ApiKey, useGetUserApiKeys } from 'hooks/Network/ApiKeys';
import { Column } from 'models/Table';
type Props = {
userId: string;
};
const useApiKeyTable = ({ userId }: Props) => {
const { t } = useTranslation();
const hiddenColumns = React.useState<string[]>([]);
const query = useGetUserApiKeys({ userId });
const dateCell = React.useCallback((date: number) => <FormattedDate date={date} />, []);
const actionCell = React.useCallback((apiKey: ApiKey) => <ApiKeyActions apiKey={apiKey} />, []);
const descriptionCell = React.useCallback((apiKey: ApiKey) => <ApiKeyDescriptionCell apiKey={apiKey} />, []);
const columns = React.useMemo(
(): Column<ApiKey>[] => [
{
id: 'name',
Header: t('common.name'),
Footer: '',
accessor: 'name',
alwaysShow: true,
customWidth: '120px',
},
{
id: 'expiresOn',
Header: t('keys.expires'),
Footer: '',
Cell: ({ cell }) => dateCell(cell.row.original.expiresOn),
accessor: 'expiresOn',
customWidth: '120px',
},
{
id: 'lastUse',
Header: t('common.last_use'),
Footer: '',
Cell: ({ cell }) => dateCell(cell.row.original.lastUse),
accessor: 'lastUse',
customWidth: '120px',
},
{
id: 'description',
Header: t('common.description'),
Footer: '',
Cell: ({ cell }) => descriptionCell(cell.row.original),
accessor: 'description',
},
{
id: 'actions',
Header: t('common.actions'),
Footer: '',
Cell: (v) => actionCell(v.cell.row.original),
disableSortBy: true,
customWidth: '120px',
alwaysShow: true,
},
],
[t],
);
return React.useMemo(
() => ({
query,
columns,
hiddenColumns,
}),
[query, columns, hiddenColumns],
);
};
export default useApiKeyTable;

View File

@@ -0,0 +1,19 @@
import * as React from 'react';
import ApiKeyTable from './Table';
import { Card } from 'components/Containers/Card';
import { CardBody } from 'components/Containers/Card/CardBody';
import { useAuth } from 'contexts/AuthProvider';
const ApiKeysCard = () => {
const { user } = useAuth();
return (
<Card p={4}>
<CardBody>
<ApiKeyTable userId={user?.id ?? ''} />
</CardBody>
</Card>
);
};
export default ApiKeysCard;

View File

@@ -1,5 +1,6 @@
import * as React from 'react';
import Masonry from 'react-masonry-css';
import ApiKeysCard from './ApiKeys';
import GeneralInformationProfile from './GeneralInformation';
import MultiFactorAuthProfile from './MultiFactorAuth';
import ProfileNotes from './Notes';
@@ -10,7 +11,7 @@ const ProfileLayout = () => (
breakpointCols={{
default: 3,
1800: 2,
1100: 1,
1200: 1,
}}
className="my-masonry-grid"
columnClassName="my-masonry-grid_column"
@@ -19,6 +20,7 @@ const ProfileLayout = () => (
<MultiFactorAuthProfile />
<GeneralInformationProfile />
<ProfileNotes />
<ApiKeysCard />
</Masonry>
);