Skip to content

Commit

Permalink
feat(user): ✨ Revokable API tokens
Browse files Browse the repository at this point in the history
  • Loading branch information
baptisteArno committed Jun 3, 2022
1 parent e5d7f1d commit a0929c4
Show file tree
Hide file tree
Showing 20 changed files with 472 additions and 43 deletions.
Original file line number Diff line number Diff line change
@@ -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>
)
}
Original file line number Diff line number Diff line change
@@ -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>
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { ApiTokensList } from './ApiTokensList'
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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())
Expand All @@ -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"
Expand Down Expand Up @@ -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">
Expand All @@ -122,6 +103,8 @@ export const MyAccountForm = () => {
</Button>
</Flex>
)}

{user && <ApiTokensList user={user} />}
</Stack>
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { MyAccountForm } from './MyAccountForm'
Original file line number Diff line number Diff line change
Expand Up @@ -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>
)
Expand Down
6 changes: 4 additions & 2 deletions apps/builder/pages/api/auth/adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,10 @@ import {
WorkspaceRole,
WorkspaceInvitation,
} from 'db'
import { randomUUID } from 'crypto'
import type { Adapter, AdapterUser } from 'next-auth/adapters'
import cuid from 'cuid'
import { got } from 'got'
import { generateId } from 'utils'

type InvitationWithWorkspaceId = Invitation & {
typebot: {
Expand Down Expand Up @@ -38,7 +38,9 @@ export function CustomAdapter(p: PrismaClient): Adapter {
data: {
...data,
id: user.id,
apiToken: randomUUID(),
apiTokens: {
create: { name: 'Default', token: generateId(24) },
},
workspaces:
workspaceInvitations.length > 0
? undefined
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
const user = await getAuthenticatedUser(req)
if (!user) return notAuthenticated(res)

const id = req.query.id.toString()
const id = req.query.userId.toString()
if (req.method === 'PUT') {
const data = typeof req.body === 'string' ? JSON.parse(req.body) : req.body
const typebots = await prisma.user.update({
Expand Down
39 changes: 39 additions & 0 deletions apps/builder/pages/api/users/[userId]/api-tokens.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { withSentry } from '@sentry/nextjs'
import prisma from 'libs/prisma'
import { NextApiRequest, NextApiResponse } from 'next'
import { getAuthenticatedUser } from 'services/api/utils'
import { generateId, methodNotAllowed, notAuthenticated } from 'utils'

const handler = async (req: NextApiRequest, res: NextApiResponse) => {
const user = await getAuthenticatedUser(req)
if (!user) return notAuthenticated(res)
if (req.method === 'GET') {
const apiTokens = await prisma.apiToken.findMany({
where: { ownerId: user.id },
select: {
id: true,
name: true,
createdAt: true,
},
orderBy: { createdAt: 'desc' },
})
return res.send({ apiTokens })
}
if (req.method === 'POST') {
const data = typeof req.body === 'string' ? JSON.parse(req.body) : req.body
const apiToken = await prisma.apiToken.create({
data: { name: data.name, ownerId: user.id, token: generateId(24) },
})
return res.send({
apiToken: {
id: apiToken.id,
name: apiToken.name,
createdAt: apiToken.createdAt,
token: apiToken.token,
},
})
}
methodNotAllowed(res)
}

export default withSentry(handler)
Loading

4 comments on commit a0929c4

@vercel
Copy link

@vercel vercel bot commented on a0929c4 Jun 3, 2022

@vercel
Copy link

@vercel vercel bot commented on a0929c4 Jun 3, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@vercel
Copy link

@vercel vercel bot commented on a0929c4 Jun 3, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@vercel
Copy link

@vercel vercel bot commented on a0929c4 Jun 3, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Successfully deployed to the following URLs:

builder-v2 – ./apps/builder

app.typebot.io
builder-v2-typebot-io.vercel.app
builder-v2-git-main-typebot-io.vercel.app

Please sign in to comment.