Commit a21554b1 authored by tom goriunov's avatar tom goriunov Committed by GitHub

Merge pull request #73 from blockscout/api-keys-backend

api keys: backend integration
parents e4dd9486 cb9ff16a
export const apiKey = [
{
token: '6fd12fe0-841c-4abf-ac2a-8c1b08dadf8e',
name: 'zapper.fi',
},
{
token: '057085a1-d2eb-4d8d-8b89-1dd9fba32071',
name: 'TenderlyBlaBlaName',
},
{
token: '057085a1-d2eb-4d8d-8b89-1dd9fba32071',
name: 'Application name',
},
];
export type TApiKey = Array<TApiKeyItem>
export type TApiKeyItem = {
token: string;
name: string;
}
import { ChakraProvider } from '@chakra-ui/react'; import { ChakraProvider } from '@chakra-ui/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
import type { AppProps } from 'next/app'; import type { AppProps } from 'next/app';
import React, { useState } from 'react'; import React, { useState } from 'react';
...@@ -19,6 +20,7 @@ function MyApp({ Component, pageProps }: AppProps) { ...@@ -19,6 +20,7 @@ function MyApp({ Component, pageProps }: AppProps) {
<ChakraProvider theme={ theme }> <ChakraProvider theme={ theme }>
<Component { ...pageProps }/> <Component { ...pageProps }/>
</ChakraProvider> </ChakraProvider>
<ReactQueryDevtools/>
</QueryClientProvider> </QueryClientProvider>
); );
} }
......
import type { NextApiRequest } from 'next';
import handler from 'pages/api/utils/handler';
const getUrl = (req: NextApiRequest) => {
return `/account/v1/user/api_keys/${ req.query.id }`;
};
const apiKeysHandler = handler(getUrl, [ 'DELETE', 'PUT' ]);
export default apiKeysHandler;
import type { ApiKeys } from 'pages/api/types/account';
import handler from 'pages/api/utils/handler';
const apiKeysHandler = handler<ApiKeys>(() => '/account/v1/user/api_keys', [ 'GET', 'POST' ]);
export default apiKeysHandler;
...@@ -10,8 +10,8 @@ export interface AddressTag { ...@@ -10,8 +10,8 @@ export interface AddressTag {
export type AddressTags = Array<AddressTag> export type AddressTags = Array<AddressTag>
export interface ApiKey { export interface ApiKey {
apiKey: string; api_key: string;
apiKeyName: string; name: string;
} }
export type ApiKeys = Array<ApiKey> export type ApiKeys = Array<ApiKey>
......
...@@ -22,6 +22,7 @@ export default function handler<TRes>(getUrl: (_req: NextApiRequest) => string, ...@@ -22,6 +22,7 @@ export default function handler<TRes>(getUrl: (_req: NextApiRequest) => string,
} else if (allowedMethods.includes('PUT') && _req.method === 'PUT') { } else if (allowedMethods.includes('PUT') && _req.method === 'PUT') {
const response = await fetch(getUrl(_req), { const response = await fetch(getUrl(_req), {
method: 'PUT', method: 'PUT',
body: _req.body,
}); });
const data = await response.json() as TRes; const data = await response.json() as TRes;
......
...@@ -5,14 +5,16 @@ import { ...@@ -5,14 +5,16 @@ import {
FormLabel, FormLabel,
Input, Input,
} from '@chakra-ui/react'; } from '@chakra-ui/react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import React, { useCallback, useEffect } from 'react'; import React, { useCallback, useEffect } from 'react';
import type { SubmitHandler, ControllerRenderProps } from 'react-hook-form'; import type { SubmitHandler, ControllerRenderProps } from 'react-hook-form';
import { useForm, Controller } from 'react-hook-form'; import { useForm, Controller } from 'react-hook-form';
import type { TApiKeyItem } from 'data/apiKey'; import type { ApiKey, ApiKeys } from 'pages/api/types/account';
type Props = { type Props = {
data?: TApiKeyItem; data?: ApiKey;
onClose: () => void;
} }
type Inputs = { type Inputs = {
...@@ -23,16 +25,54 @@ type Inputs = { ...@@ -23,16 +25,54 @@ type Inputs = {
// idk, maybe there is no limit // idk, maybe there is no limit
const NAME_MAX_LENGTH = 100; const NAME_MAX_LENGTH = 100;
const ApiKeyForm: React.FC<Props> = ({ data }) => { const ApiKeyForm: React.FC<Props> = ({ data, onClose }) => {
const { control, handleSubmit, formState: { errors }, setValue } = useForm<Inputs>(); const { control, handleSubmit, formState: { errors }, setValue } = useForm<Inputs>();
const queryClient = useQueryClient();
useEffect(() => { useEffect(() => {
setValue('token', data?.token || ''); setValue('token', data?.api_key || '');
setValue('name', data?.name || ''); setValue('name', data?.name || '');
}, [ setValue, data ]); }, [ setValue, data ]);
// eslint-disable-next-line no-console const updateApiKey = (data: Inputs) => {
const onSubmit: SubmitHandler<Inputs> = data => console.log(data); const body = JSON.stringify({ name: data.name });
if (!data.token) {
return fetch('/api/account/api-keys', { method: 'POST', body });
}
return fetch(`/api/account/api-keys/${ data.token }`, { method: 'PUT', body });
};
const mutation = useMutation(updateApiKey, {
onSuccess: async(data) => {
const response: ApiKey = await data.json();
queryClient.setQueryData([ 'api-keys' ], (prevData: ApiKeys | undefined) => {
const isExisting = prevData && prevData.some((item) => item.api_key === response.api_key);
if (isExisting) {
return prevData.map((item) => {
if (item.api_key === response.api_key) {
return response;
}
return item;
});
}
return [ ...(prevData || []), response ];
});
onClose();
},
// eslint-disable-next-line no-console
onError: console.error,
});
const onSubmit: SubmitHandler<Inputs> = useCallback((data) => {
mutation.mutate(data);
}, [ mutation ]);
const renderTokenInput = useCallback(({ field }: {field: ControllerRenderProps<Inputs, 'token'>}) => { const renderTokenInput = useCallback(({ field }: {field: ControllerRenderProps<Inputs, 'token'>}) => {
return ( return (
...@@ -86,6 +126,7 @@ const ApiKeyForm: React.FC<Props> = ({ data }) => { ...@@ -86,6 +126,7 @@ const ApiKeyForm: React.FC<Props> = ({ data }) => {
variant="primary" variant="primary"
onClick={ handleSubmit(onSubmit) } onClick={ handleSubmit(onSubmit) }
disabled={ Object.keys(errors).length > 0 } disabled={ Object.keys(errors).length > 0 }
isLoading={ mutation.isLoading }
> >
{ data ? 'Save' : 'Generate API key' } { data ? 'Save' : 'Generate API key' }
</Button> </Button>
......
import React, { useCallback } from 'react'; import React, { useCallback } from 'react';
import type { TApiKeyItem } from 'data/apiKey'; import type { ApiKey } from 'pages/api/types/account';
import FormModal from 'ui/shared/FormModal'; import FormModal from 'ui/shared/FormModal';
import ApiKeyForm from './ApiKeyForm'; import ApiKeyForm from './ApiKeyForm';
...@@ -8,7 +9,7 @@ import ApiKeyForm from './ApiKeyForm'; ...@@ -8,7 +9,7 @@ import ApiKeyForm from './ApiKeyForm';
type Props = { type Props = {
isOpen: boolean; isOpen: boolean;
onClose: () => void; onClose: () => void;
data?: TApiKeyItem; data?: ApiKey;
} }
const AddressModal: React.FC<Props> = ({ isOpen, onClose, data }) => { const AddressModal: React.FC<Props> = ({ isOpen, onClose, data }) => {
...@@ -16,10 +17,10 @@ const AddressModal: React.FC<Props> = ({ isOpen, onClose, data }) => { ...@@ -16,10 +17,10 @@ const AddressModal: React.FC<Props> = ({ isOpen, onClose, data }) => {
const text = 'Add an application name to identify your API key. Click the button below to auto-generate the associated key.'; const text = 'Add an application name to identify your API key. Click the button below to auto-generate the associated key.';
const renderForm = useCallback(() => { const renderForm = useCallback(() => {
return <ApiKeyForm data={ data }/>; return <ApiKeyForm data={ data } onClose={ onClose }/>;
}, [ data ]); }, [ data, onClose ]);
return ( return (
<FormModal<TApiKeyItem> <FormModal<ApiKey>
isOpen={ isOpen } isOpen={ isOpen }
onClose={ onClose } onClose={ onClose }
title={ title } title={ title }
......
...@@ -8,14 +8,14 @@ import { ...@@ -8,14 +8,14 @@ import {
} from '@chakra-ui/react'; } from '@chakra-ui/react';
import React from 'react'; import React from 'react';
import type { TApiKey, TApiKeyItem } from 'data/apiKey'; import type { ApiKeys, ApiKey } from 'pages/api/types/account';
import ApiKeyTableItem from './ApiKeyTableItem'; import ApiKeyTableItem from './ApiKeyTableItem';
interface Props { interface Props {
data: TApiKey; data: ApiKeys;
onEditClick: (data: TApiKeyItem) => void; onEditClick: (item: ApiKey) => void;
onDeleteClick: (data: TApiKeyItem) => void; onDeleteClick: (item: ApiKey) => void;
limit: number; limit: number;
} }
...@@ -33,7 +33,7 @@ const ApiKeyTable = ({ data, onDeleteClick, onEditClick, limit }: Props) => { ...@@ -33,7 +33,7 @@ const ApiKeyTable = ({ data, onDeleteClick, onEditClick, limit }: Props) => {
{ data.map((item) => ( { data.map((item) => (
<ApiKeyTableItem <ApiKeyTableItem
item={ item } item={ item }
key={ item.token } key={ item.api_key }
onDeleteClick={ onDeleteClick } onDeleteClick={ onDeleteClick }
onEditClick={ onEditClick } onEditClick={ onEditClick }
/> />
......
...@@ -6,15 +6,16 @@ import { ...@@ -6,15 +6,16 @@ import {
} from '@chakra-ui/react'; } from '@chakra-ui/react';
import React, { useCallback } from 'react'; import React, { useCallback } from 'react';
import type { TApiKeyItem } from 'data/apiKey'; import type { ApiKey } from 'pages/api/types/account';
import CopyToClipboard from 'ui/shared/CopyToClipboard'; import CopyToClipboard from 'ui/shared/CopyToClipboard';
import DeleteButton from 'ui/shared/DeleteButton'; import DeleteButton from 'ui/shared/DeleteButton';
import EditButton from 'ui/shared/EditButton'; import EditButton from 'ui/shared/EditButton';
interface Props { interface Props {
item: TApiKeyItem; item: ApiKey;
onEditClick: (data: TApiKeyItem) => void; onEditClick: (item: ApiKey) => void;
onDeleteClick: (data: TApiKeyItem) => void; onDeleteClick: (item: ApiKey) => void;
} }
const WatchlistTableItem = ({ item, onEditClick, onDeleteClick }: Props) => { const WatchlistTableItem = ({ item, onEditClick, onDeleteClick }: Props) => {
...@@ -28,11 +29,11 @@ const WatchlistTableItem = ({ item, onEditClick, onDeleteClick }: Props) => { ...@@ -28,11 +29,11 @@ const WatchlistTableItem = ({ item, onEditClick, onDeleteClick }: Props) => {
}, [ item, onDeleteClick ]); }, [ item, onDeleteClick ]);
return ( return (
<Tr alignItems="top" key={ item.token }> <Tr alignItems="top" key={ item.api_key }>
<Td> <Td>
<HStack> <HStack>
<Text fontSize="md" fontWeight={ 600 }>{ item.token }</Text> <Text fontSize="md" fontWeight={ 600 }>{ item.api_key }</Text>
<CopyToClipboard text={ item.token }/> <CopyToClipboard text={ item.api_key }/>
</HStack> </HStack>
<Text fontSize="sm" marginTop={ 0.5 } variant="secondary">{ item.name }</Text> <Text fontSize="sm" marginTop={ 0.5 } variant="secondary">{ item.name }</Text>
</Td> </Td>
......
import { Text } from '@chakra-ui/react'; import { Text } from '@chakra-ui/react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import React, { useCallback } from 'react'; import React, { useCallback } from 'react';
import type { ApiKey, ApiKeys } from 'pages/api/types/account';
import DeleteModal from 'ui/shared/DeleteModal'; import DeleteModal from 'ui/shared/DeleteModal';
type Props = { type Props = {
isOpen: boolean; isOpen: boolean;
onClose: () => void; onClose: () => void;
name?: string; data: ApiKey;
} }
const DeleteAddressModal: React.FC<Props> = ({ isOpen, onClose, name }) => { const DeleteAddressModal: React.FC<Props> = ({ isOpen, onClose, data }) => {
const onDelete = useCallback(() => {
const queryClient = useQueryClient();
const deleteApiKey = () => {
return fetch(`/api/account/api-keys/${ data.api_key }`, { method: 'DELETE' });
};
const mutation = useMutation(deleteApiKey, {
onSuccess: async() => {
queryClient.setQueryData([ 'api-keys' ], (prevData: ApiKeys | undefined) => {
return prevData?.filter((item) => item.api_key !== data.api_key);
});
onClose();
},
// eslint-disable-next-line no-console // eslint-disable-next-line no-console
console.log('delete', name); onError: console.error,
}, [ name ]); });
const onDelete = useCallback(() => {
mutation.mutate(data);
}, [ data, mutation ]);
const renderText = useCallback(() => { const renderText = useCallback(() => {
return ( return (
<Text display="flex">API key for<Text fontWeight="600" whiteSpace="pre">{ ` "${ name || 'name' }" ` }</Text>will be deleted</Text> <Text display="flex">API key for<Text fontWeight="600" whiteSpace="pre">{ ` "${ data.name || 'name' }" ` }</Text>will be deleted</Text>
); );
}, [ name ]); }, [ data.name ]);
return ( return (
<DeleteModal <DeleteModal
isOpen={ isOpen } isOpen={ isOpen }
...@@ -27,6 +49,7 @@ const DeleteAddressModal: React.FC<Props> = ({ isOpen, onClose, name }) => { ...@@ -27,6 +49,7 @@ const DeleteAddressModal: React.FC<Props> = ({ isOpen, onClose, name }) => {
onDelete={ onDelete } onDelete={ onDelete }
title="Remove API key" title="Remove API key"
renderContent={ renderText } renderContent={ renderText }
pending={ mutation.isLoading }
/> />
); );
}; };
......
import { Box, Button, HStack, Link, Text, useDisclosure } from '@chakra-ui/react'; import { Box, Button, HStack, Link, Text, Spinner, useDisclosure } from '@chakra-ui/react';
import { useQuery } from '@tanstack/react-query';
import React, { useCallback, useState } from 'react'; import React, { useCallback, useState } from 'react';
import type { TApiKeyItem } from 'data/apiKey'; import type { ApiKey, ApiKeys } from 'pages/api/types/account';
import { apiKey } from 'data/apiKey';
import { space } from 'lib/html-entities'; import { space } from 'lib/html-entities';
import ApiKeyModal from 'ui/apiKey/ApiKeyModal/ApiKeyModal'; import ApiKeyModal from 'ui/apiKey/ApiKeyModal/ApiKeyModal';
import ApiKeyTable from 'ui/apiKey/ApiKeyTable/ApiKeyTable'; import ApiKeyTable from 'ui/apiKey/ApiKeyTable/ApiKeyTable';
...@@ -12,14 +13,22 @@ import Page from 'ui/shared/Page/Page'; ...@@ -12,14 +13,22 @@ import Page from 'ui/shared/Page/Page';
const DATA_LIMIT = 3; const DATA_LIMIT = 3;
const ApiKeys: React.FC = () => { const ApiKeysPage: React.FC = () => {
const apiKeyModalProps = useDisclosure(); const apiKeyModalProps = useDisclosure();
const deleteModalProps = useDisclosure(); const deleteModalProps = useDisclosure();
const [ apiKeyModalData, setApiKeyModalData ] = useState<TApiKeyItem>(); const [ apiKeyModalData, setApiKeyModalData ] = useState<ApiKey>();
const [ deleteModalData, setDeleteModalData ] = useState<string>(); const [ deleteModalData, setDeleteModalData ] = useState<ApiKey>();
const { data, isLoading, isError } = useQuery<unknown, unknown, ApiKeys>([ 'api-keys' ], async() => {
const response = await fetch('/api/account/api-keys');
if (!response.ok) {
throw new Error('Network response was not ok');
}
return response.json();
});
const onEditClick = useCallback((data: TApiKeyItem) => { const onEditClick = useCallback((data: ApiKey) => {
setApiKeyModalData(data); setApiKeyModalData(data);
apiKeyModalProps.onOpen(); apiKeyModalProps.onOpen();
}, [ apiKeyModalProps ]); }, [ apiKeyModalProps ]);
...@@ -29,8 +38,8 @@ const ApiKeys: React.FC = () => { ...@@ -29,8 +38,8 @@ const ApiKeys: React.FC = () => {
apiKeyModalProps.onClose(); apiKeyModalProps.onClose();
}, [ apiKeyModalProps ]); }, [ apiKeyModalProps ]);
const onDeleteClick = useCallback((data: TApiKeyItem) => { const onDeleteClick = useCallback((data: ApiKey) => {
setDeleteModalData(data.name); setDeleteModalData(data);
deleteModalProps.onOpen(); deleteModalProps.onOpen();
}, [ deleteModalProps ]); }, [ deleteModalProps ]);
...@@ -39,19 +48,17 @@ const ApiKeys: React.FC = () => { ...@@ -39,19 +48,17 @@ const ApiKeys: React.FC = () => {
deleteModalProps.onClose(); deleteModalProps.onClose();
}, [ deleteModalProps ]); }, [ deleteModalProps ]);
const canAdd = apiKey.length < DATA_LIMIT; const content = (() => {
if (isLoading || isError) {
return <Spinner/>;
}
return ( const canAdd = data.length < DATA_LIMIT;
<Page> return (
<Box h="100%"> <>
<AccountPageHeader text="API keys"/> { data.length > 0 && (
<Text marginBottom={ 12 }>
Create API keys to use for your RPC and EthRPC API requests. For more information, see { space }
<Link href="#">“How to use a Blockscout API key”</Link>.
</Text>
{ Boolean(apiKey.length) && (
<ApiKeyTable <ApiKeyTable
data={ apiKey } data={ data }
onDeleteClick={ onDeleteClick } onDeleteClick={ onDeleteClick }
onEditClick={ onEditClick } onEditClick={ onEditClick }
limit={ DATA_LIMIT } limit={ DATA_LIMIT }
...@@ -72,11 +79,24 @@ const ApiKeys: React.FC = () => { ...@@ -72,11 +79,24 @@ const ApiKeys: React.FC = () => {
</Text> </Text>
) } ) }
</HStack> </HStack>
</>
);
})();
return (
<Page>
<Box h="100%">
<AccountPageHeader text="API keys"/>
<Text marginBottom={ 12 }>
Create API keys to use for your RPC and EthRPC API requests. For more information, see { space }
<Link href="#">“How to use a Blockscout API key”</Link>.
</Text>
{ content }
</Box> </Box>
<ApiKeyModal { ...apiKeyModalProps } onClose={ onApiKeyModalClose } data={ apiKeyModalData }/> <ApiKeyModal { ...apiKeyModalProps } onClose={ onApiKeyModalClose } data={ apiKeyModalData }/>
<DeleteApiKeyModal { ...deleteModalProps } onClose={ onDeleteModalClose } name={ deleteModalData }/> { deleteModalData && <DeleteApiKeyModal { ...deleteModalProps } onClose={ onDeleteModalClose } data={ deleteModalData }/> }
</Page> </Page>
); );
}; };
export default ApiKeys; export default ApiKeysPage;
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment