[WIFI-11543] Added API keys support

Signed-off-by: Charles <charles.bourque96@gmail.com>
This commit is contained in:
Charles
2022-11-11 11:22:26 +00:00
parent 169dec387c
commit 29c20bb79e
20 changed files with 876 additions and 80 deletions

29
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "wlan-cloud-owprov-ui",
"version": "2.8.0(12)",
"version": "2.8.0(13)",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "wlan-cloud-owprov-ui",
"version": "2.8.0(12)",
"version": "2.8.0(13)",
"license": "ISC",
"dependencies": {
"@chakra-ui/icons": "^2.0.11",
@@ -64,6 +64,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",
@@ -4089,6 +4090,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.7",
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.0.7.tgz",
@@ -14609,6 +14622,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.7",
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.0.7.tgz",

View File

@@ -1,6 +1,6 @@
{
"name": "wlan-cloud-owprov-ui",
"version": "2.8.0(12)",
"version": "2.8.0(13)",
"description": "",
"main": "index.tsx",
"scripts": {
@@ -72,6 +72,7 @@
"@types/react-dom": "^18.0.6",
"@types/react-table": "^7.7.12",
"@types/react-virtualized-auto-sizer": "^1.0.1",
"@types/react-datepicker": "4.8.0",
"@types/react-window": "^1.8.5",
"@types/uuid": "^8.3.4",
"eslint": "8.25.0",

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",
@@ -745,7 +748,9 @@
"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",
"name_error": "Der Name sollte eindeutig sein und zwischen 6 und 20 Zeichen lang sein",
"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"
},

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",
@@ -745,7 +748,9 @@
"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",
"name_error": "Name should be unique and be between 6 and 20 characters",
"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"
},

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",
@@ -745,7 +748,9 @@
"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",
"name_error": "El nombre debe ser único y tener entre 6 y 20 caracteres",
"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"
},

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",
@@ -745,7 +748,9 @@
"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",
"name_error": "Le nom doit être unique et comporter entre 6 et 20 caractères",
"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"
},

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",
@@ -745,7 +748,9 @@
"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",
"name_error": "O nome deve ser único e ter entre 6 e 20 caracteres",
"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"
},

View File

@@ -25,15 +25,28 @@ import {
useBreakpoint,
} from '@chakra-ui/react';
import { useTranslation } from 'react-i18next';
import { useTable, usePagination, useSortBy, Row } from 'react-table';
import {
useTable,
usePagination,
useSortBy,
Row,
TableInstance,
UsePaginationInstanceProps,
UseSortByInstanceProps,
UsePaginationState,
} from 'react-table';
import { v4 as uuid } from 'uuid';
// @ts-ignore
import SortIcon from './SortIcon';
import LoadingOverlay from 'components/LoadingOverlay';
import { Column, PageInfo } from 'models/Table';
interface Props {
columns: Column[];
const defaultProps = {
sortBy: [],
};
type DataTableProps = {
columns: readonly Column<object>[];
data: object[];
count?: number;
setPageInfo?: React.Dispatch<React.SetStateAction<PageInfo | undefined>>;
@@ -42,25 +55,19 @@ interface Props {
sortBy?: { id: string; desc: boolean }[];
hiddenColumns?: string[];
hideControls?: boolean;
minHeight?: string;
minHeight?: string | number;
fullScreen?: boolean;
isManual?: boolean;
saveSettingsId?: string;
}
const defaultProps = {
count: undefined,
setPageInfo: undefined,
isLoading: false,
minHeight: undefined,
fullScreen: false,
sortBy: [],
hiddenColumns: [],
hideControls: false,
isManual: false,
saveSettingsId: undefined,
showAllRows?: boolean;
};
type TableInstanceWithHooks<T extends object> = TableInstance<T> &
UsePaginationInstanceProps<T> &
UseSortByInstanceProps<T> & {
state: UsePaginationState<T>;
};
const DataTable = ({
columns,
data,
@@ -75,14 +82,31 @@ const DataTable = ({
setPageInfo,
isManual,
saveSettingsId,
}: Props) => {
showAllRows,
}: DataTableProps) => {
const { t } = useTranslation();
const breakpoint = useBreakpoint();
const textColor = useColorModeValue('gray.700', 'white');
const getPageSize = () => {
const saved = saveSettingsId ? localStorage.getItem(saveSettingsId) : undefined;
if (saved) return Number.parseInt(saved, 10);
return 10;
try {
if (showAllRows) return 1000000;
const saved = saveSettingsId ? localStorage.getItem(saveSettingsId) : undefined;
if (saved) return Number.parseInt(saved, 10);
return 10;
} catch {
return 10;
}
};
const getPageIndex = () => {
try {
if (saveSettingsId) {
const saved = localStorage.getItem(`${saveSettingsId}.page`);
if (saved) return Number.parseInt(saved, 10);
}
return 0;
} catch {
return 0;
}
};
const [queryPageSize, setQueryPageSize] = useState(getPageSize());
@@ -104,25 +128,49 @@ const DataTable = ({
state: { pageIndex, pageSize },
} = useTable(
{
// @ts-ignore
columns,
data,
// @ts-ignore
initialState: { sortBy, pagination: !hideControls, pageSize: queryPageSize },
manualPagination: isManual,
pageCount: isManual && count !== undefined ? Math.ceil(count / queryPageSize) : undefined,
pageCount:
isManual && count !== undefined
? Math.ceil(count / queryPageSize)
: Math.ceil(data?.length ?? 0 / queryPageSize),
},
useSortBy,
usePagination,
);
) as TableInstanceWithHooks<object>;
const handleGoToPage = (newPage: number) => {
if (saveSettingsId) localStorage.setItem(`${saveSettingsId}.page`, String(newPage));
gotoPage(newPage);
};
const handleNextPage = () => {
nextPage();
if (saveSettingsId) localStorage.setItem(`${saveSettingsId}.page`, String(pageIndex + 1));
};
const handlePreviousPage = () => {
previousPage();
if (saveSettingsId) localStorage.setItem(`${saveSettingsId}.page`, String(pageIndex - 1));
};
useEffect(() => {
if (setPageInfo && pageIndex !== undefined) setPageInfo({ index: pageIndex, limit: queryPageSize });
}, [queryPageSize, pageIndex]);
useEffect(() => {
// @ts-ignore
if (saveSettingsId) localStorage.setItem(saveSettingsId, pageSize);
setQueryPageSize(pageSize);
}, [pageSize]);
useEffect(() => {
if (isManual && count !== undefined) {
gotoPage(getPageIndex());
}
}, [count]);
useEffect(() => {
if (hiddenColumns) setHiddenColumns(hiddenColumns);
}, [hiddenColumns]);
@@ -155,6 +203,7 @@ const DataTable = ({
</Center>
);
}
// Render the UI for your table
return (
<>
@@ -186,6 +235,7 @@ const DataTable = ({
alignContent: 'center',
overflow: 'hidden',
whiteSpace: 'nowrap',
// @ts-ignore
paddingTop: column.canSort ? '' : '4px',
}}
>
@@ -264,7 +314,7 @@ const DataTable = ({
<Tooltip label={t('table.first_page')}>
<IconButton
aria-label="Go to first page"
onClick={() => gotoPage(0)}
onClick={() => handleGoToPage(0)}
isDisabled={!canPreviousPage}
icon={<ArrowLeftIcon h={3} w={3} />}
mr={4}
@@ -273,7 +323,7 @@ const DataTable = ({
<Tooltip label={t('table.previous_page')}>
<IconButton
aria-label="Previous page"
onClick={previousPage}
onClick={handlePreviousPage}
isDisabled={!canPreviousPage}
icon={<ChevronLeftIcon h={6} w={6} />}
/>
@@ -302,7 +352,7 @@ const DataTable = ({
max={pageOptions.length}
onChange={(_: unknown, numberValue: number) => {
const newPage = numberValue ? numberValue - 1 : 0;
gotoPage(newPage);
handleGoToPage(newPage);
}}
defaultValue={pageIndex + 1}
>
@@ -333,7 +383,7 @@ const DataTable = ({
<Tooltip label={t('table.next_page')}>
<IconButton
aria-label="Go to next page"
onClick={nextPage}
onClick={handleNextPage}
isDisabled={!canNextPage}
icon={<ChevronRightIcon h={6} w={6} />}
/>
@@ -341,7 +391,7 @@ const DataTable = ({
<Tooltip label={t('table.last_page')}>
<IconButton
aria-label="Go to last page"
onClick={() => gotoPage(pageCount - 1)}
onClick={() => handleGoToPage(pageCount - 1)}
isDisabled={!canNextPage}
icon={<ArrowRightIcon h={3} w={3} />}
ml={4}
@@ -356,4 +406,4 @@ const DataTable = ({
DataTable.defaultProps = defaultProps;
export default DataTable;
export default React.memo(DataTable);

View File

@@ -1,23 +0,0 @@
import React, { forwardRef } from 'react';
import { Button } from '@chakra-ui/react';
import PropTypes from 'prop-types';
const propTypes = {
value: PropTypes.string,
onClick: PropTypes.func,
};
const defaultProps = {
value: '',
onClick: () => {},
};
const DatePickerInput = forwardRef(({ value, onClick }, ref) => (
<Button colorScheme="gray" onClick={onClick} ref={ref}>
{value}
</Button>
));
DatePickerInput.propTypes = propTypes;
DatePickerInput.defaultProps = defaultProps;
export default DatePickerInput;

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

@@ -1,27 +1,20 @@
import React from 'react';
import PropTypes from 'prop-types';
import DatePicker from 'react-datepicker';
import { useTranslation } from 'react-i18next';
import 'react-datepicker/dist/react-datepicker.css';
import DatePickerInput from '../DatePickerInput';
const propTypes = {
date: PropTypes.instanceOf(Date).isRequired,
onChange: PropTypes.func.isRequired,
isStart: PropTypes.bool,
isEnd: PropTypes.bool,
startDate: PropTypes.instanceOf(Date),
endDate: PropTypes.instanceOf(Date),
type Props = {
date: Date;
onChange: (v: Date | null) => void;
isStart?: boolean;
isEnd?: boolean;
startDate?: Date;
endDate?: Date;
isDisabled?: boolean;
};
const defaultProps = {
isStart: false,
isEnd: false,
startDate: null,
endDate: null,
};
const DateTimePicker = ({ date, onChange, isStart, isEnd, startDate, endDate }) => {
const DateTimePicker = ({ date, onChange, isStart, isEnd, startDate, endDate, isDisabled }: Props) => {
const { t } = useTranslation();
return (
@@ -36,11 +29,9 @@ const DateTimePicker = ({ date, onChange, isStart, isEnd, startDate, endDate })
dateFormat="dd/MM/yyyy hh:mm aa"
timeFormat="p"
showTimeSelect
customInput={<DatePickerInput />}
customInput={<DatePickerInput isDisabled={isDisabled} />}
/>
);
};
DateTimePicker.propTypes = propTypes;
DateTimePicker.defaultProps = defaultProps;
export default React.memo(DateTimePicker);

View File

@@ -0,0 +1,84 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { AxiosError } from 'models/Axios';
import { axiosSec } from 'utils/axiosInstances';
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 CreateButton from '../../../../components/Buttons/CreateButton';
import { Modal } from '../../../../components/Modals/Modal';
import ApiKeyExpiresOnField from './ExpiresOnField';
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/ColumnPicker';
import DataTable from 'components/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/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/Card';
import CardBody from 'components/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>
);