feat(user): ✨ Revokable API tokens
This commit is contained in:
@ -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>
|
||||
)
|
||||
}
|
@ -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>
|
||||
)
|
||||
}
|
@ -0,0 +1 @@
|
||||
export { ApiTokensList } from './ApiTokensList'
|
@ -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>
|
||||
)
|
||||
}
|
@ -0,0 +1 @@
|
||||
export { MyAccountForm } from './MyAccountForm'
|
@ -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>
|
||||
)
|
||||
|
Reference in New Issue
Block a user