2
0

feat(user): Revokable API tokens

This commit is contained in:
Baptiste Arnaud
2022-06-03 13:20:19 +02:00
parent e5d7f1d1ce
commit a0929c492b
20 changed files with 472 additions and 43 deletions

View File

@ -0,0 +1,132 @@
import {
TableContainer,
Table,
Thead,
Tr,
Th,
Tbody,
Td,
Button,
Text,
Heading,
Checkbox,
Skeleton,
Stack,
Flex,
useDisclosure,
} from '@chakra-ui/react'
import { ConfirmModal } from 'components/modals/ConfirmModal'
import { useToast } from 'components/shared/hooks/useToast'
import { User } from 'db'
import React, { useState } from 'react'
import {
ApiTokenFromServer,
deleteApiToken,
useApiTokens,
} from 'services/user/apiTokens'
import { timeSince } from 'services/utils'
import { byId, isDefined } from 'utils'
import { CreateTokenModal } from './CreateTokenModal'
type Props = { user: User }
export const ApiTokensList = ({ user }: Props) => {
const { showToast } = useToast()
const { apiTokens, isLoading, mutate } = useApiTokens({
userId: user.id,
onError: (e) =>
showToast({ title: 'Failed to fetch tokens', description: e.message }),
})
const {
isOpen: isCreateOpen,
onOpen: onCreateOpen,
onClose: onCreateClose,
} = useDisclosure()
const [deletingId, setDeletingId] = useState<string>()
const refreshListWithNewToken = (token: ApiTokenFromServer) => {
if (!apiTokens) return
mutate({ apiTokens: [token, ...apiTokens] })
}
const deleteToken = async (tokenId?: string) => {
if (!apiTokens || !tokenId) return
const { error } = await deleteApiToken({ userId: user.id, tokenId })
if (!error) mutate({ apiTokens: apiTokens.filter((t) => t.id !== tokenId) })
}
return (
<Stack spacing={4}>
<Heading fontSize="2xl">API tokens</Heading>
<Text>
These tokens allow other apps to control your whole account and
typebots. Be careful!
</Text>
<Flex justifyContent="flex-end">
<Button onClick={onCreateOpen}>Create</Button>
<CreateTokenModal
userId={user.id}
isOpen={isCreateOpen}
onNewToken={refreshListWithNewToken}
onClose={onCreateClose}
/>
</Flex>
<TableContainer>
<Table>
<Thead>
<Tr>
<Th>Name</Th>
<Th w="130px">Created</Th>
<Th w="0" />
</Tr>
</Thead>
<Tbody>
{apiTokens?.map((token) => (
<Tr key={token.id}>
<Td>{token.name}</Td>
<Td>{timeSince(token.createdAt)} ago</Td>
<Td>
<Button
size="xs"
colorScheme="red"
variant="outline"
onClick={() => setDeletingId(token.id)}
>
Delete
</Button>
</Td>
</Tr>
))}
{isLoading &&
Array.from({ length: 3 }).map((_, idx) => (
<Tr key={idx}>
<Td>
<Checkbox isDisabled />
</Td>
<Td>
<Skeleton h="5px" />
</Td>
<Td>
<Skeleton h="5px" />
</Td>
</Tr>
))}
</Tbody>
</Table>
</TableContainer>
<ConfirmModal
isOpen={isDefined(deletingId)}
onConfirm={() => deleteToken(deletingId)}
onClose={() => setDeletingId(undefined)}
message={
<Text>
The token <strong>{apiTokens?.find(byId(deletingId))?.name}</strong>{' '}
will be permanently deleted, are you sure you want to continue?
</Text>
}
confirmButtonLabel="Delete"
/>
</Stack>
)
}

View File

@ -0,0 +1,100 @@
import {
Modal,
ModalOverlay,
ModalContent,
ModalHeader,
ModalCloseButton,
ModalBody,
Stack,
Input,
ModalFooter,
Button,
Text,
InputGroup,
InputRightElement,
} from '@chakra-ui/react'
import { CopyButton } from 'components/shared/buttons/CopyButton'
import React, { FormEvent, useState } from 'react'
import { ApiTokenFromServer, createApiToken } from 'services/user/apiTokens'
type Props = {
userId: string
isOpen: boolean
onNewToken: (token: ApiTokenFromServer) => void
onClose: () => void
}
export const CreateTokenModal = ({
userId,
isOpen,
onClose,
onNewToken,
}: Props) => {
const [name, setName] = useState('')
const [isSubmitting, setIsSubmitting] = useState(false)
const [newTokenValue, setNewTokenValue] = useState<string>()
const createToken = async (e: FormEvent) => {
e.preventDefault()
setIsSubmitting(true)
const { data } = await createApiToken(userId, { name })
if (data?.apiToken) {
setNewTokenValue(data.apiToken.token)
onNewToken(data.apiToken)
}
setIsSubmitting(false)
}
return (
<Modal isOpen={isOpen} onClose={onClose}>
<ModalOverlay />
<ModalContent>
<ModalHeader>
{newTokenValue ? 'Token Created' : 'Create Token'}
</ModalHeader>
<ModalCloseButton />
{newTokenValue ? (
<ModalBody as={Stack} spacing="4">
<Text>
Please copy your token and store it in a safe place.{' '}
<strong>For security reasons we cannot show it again.</strong>
</Text>
<InputGroup size="md">
<Input readOnly pr="4.5rem" value={newTokenValue} />
<InputRightElement width="4.5rem">
<CopyButton h="1.75rem" size="sm" textToCopy={newTokenValue} />
</InputRightElement>
</InputGroup>
</ModalBody>
) : (
<ModalBody as="form" onSubmit={createToken}>
<Text mb="4">
Enter a unique name for your token to differentiate it from other
tokens.
</Text>
<Input
placeholder="I.e. Zapier, Github, Make.com"
onChange={(e) => setName(e.target.value)}
/>
</ModalBody>
)}
<ModalFooter>
{newTokenValue ? (
<Button onClick={onClose} colorScheme="blue">
Done
</Button>
) : (
<Button
colorScheme="blue"
isDisabled={name.length === 0}
isLoading={isSubmitting}
onClick={createToken}
>
Create token
</Button>
)}
</ModalFooter>
</ModalContent>
</Modal>
)
}

View File

@ -0,0 +1 @@
export { ApiTokensList } from './ApiTokensList'

View File

@ -9,14 +9,13 @@ import {
Tooltip,
Flex,
Text,
InputRightElement,
InputGroup,
} from '@chakra-ui/react'
import { UploadIcon } from 'assets/icons'
import { UploadButton } from 'components/shared/buttons/UploadButton'
import { useUser } from 'contexts/UserContext'
import React, { ChangeEvent, useState } from 'react'
import { isDefined } from 'utils'
import { ApiTokensList } from './ApiTokensList'
export const MyAccountForm = () => {
const {
@ -28,7 +27,6 @@ export const MyAccountForm = () => {
isOAuthProvider,
} = useUser()
const [reloadParam, setReloadParam] = useState('')
const [isApiTokenVisible, setIsApiTokenVisible] = useState(false)
const handleFileUploaded = async (url: string) => {
setReloadParam(Date.now().toString())
@ -43,10 +41,8 @@ export const MyAccountForm = () => {
updateUser({ email: e.target.value })
}
const toggleTokenVisibility = () => setIsApiTokenVisible(!isApiTokenVisible)
return (
<Stack spacing="6" w="full">
<Stack spacing="6" w="full" overflowY="scroll">
<HStack spacing={6}>
<Avatar
size="lg"
@ -95,21 +91,6 @@ export const MyAccountForm = () => {
</FormControl>
</Tooltip>
)}
<FormControl>
<FormLabel htmlFor="name">API token</FormLabel>
<InputGroup>
<Input
id="token"
value={user?.apiToken ?? ''}
type={isApiTokenVisible ? 'text' : 'password'}
/>
<InputRightElement mr="3">
<Button size="xs" onClick={toggleTokenVisibility}>
{isApiTokenVisible ? 'Hide' : 'Show'}
</Button>
</InputRightElement>
</InputGroup>
</FormControl>
{hasUnsavedChanges && (
<Flex justifyContent="flex-end">
@ -122,6 +103,8 @@ export const MyAccountForm = () => {
</Button>
</Flex>
)}
{user && <ApiTokensList user={user} />}
</Stack>
)
}

View File

@ -0,0 +1 @@
export { MyAccountForm } from './MyAccountForm'

View File

@ -131,9 +131,11 @@ export const WorkspaceSettingsModal = ({
)}
</Stack>
</Stack>
<Flex flex="1" p="10">
<SettingsContent tab={selectedTab} onClose={onClose} />
</Flex>
{isOpen && (
<Flex flex="1" p="10">
<SettingsContent tab={selectedTab} onClose={onClose} />
</Flex>
)}
</ModalContent>
</Modal>
)