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>
)

View File

@ -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: {
@ -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

View File

@ -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({

View File

@ -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)

View File

@ -0,0 +1,21 @@
import { withSentry } from '@sentry/nextjs'
import prisma from 'libs/prisma'
import { NextApiRequest, NextApiResponse } from 'next'
import { getAuthenticatedUser } from 'services/api/utils'
import { methodNotAllowed, notAuthenticated } from 'utils'
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
const user = await getAuthenticatedUser(req)
if (!user) return notAuthenticated(res)
if (req.method === 'DELETE') {
const id = req.query.tokenId.toString()
const apiToken = await prisma.apiToken.delete({
where: { id },
})
return res.send({ apiToken })
}
methodNotAllowed(res)
}
export default withSentry(handler)

View File

@ -62,6 +62,27 @@ export const createUsers = async () => {
email: 'pro-user@email.com',
name: 'Pro user',
graphNavigation: GraphNavigation.TRACKPAD,
apiTokens: {
createMany: {
data: [
{
name: 'Token 1',
token: 'jirowjgrwGREHEtoken1',
createdAt: new Date(2022, 1, 1),
},
{
name: 'Github',
token: 'jirowjgrwGREHEgdrgithub',
createdAt: new Date(2022, 1, 2),
},
{
name: 'N8n',
token: 'jirowjgrwGREHrgwhrwn8n',
createdAt: new Date(2022, 1, 3),
},
],
},
},
workspaces: {
create: {
role: WorkspaceRole.ADMIN,
@ -99,7 +120,7 @@ export const createUsers = async () => {
await prisma.workspace.create({
data: {
id: freeWorkspaceId,
name: 'Free Shared Workspace',
name: 'Free Shared workspace',
plan: Plan.FREE,
members: {
createMany: {
@ -114,7 +135,7 @@ export const createUsers = async () => {
return prisma.workspace.create({
data: {
id: sharedWorkspaceId,
name: 'Shared Workspace',
name: 'Shared workspace',
plan: Plan.TEAM,
members: {
createMany: {

View File

@ -26,3 +26,23 @@ test('should display user info properly', async ({ page }) => {
await page.click('text="Preferences"')
await expect(page.locator('text=Trackpad')).toBeVisible()
})
test('should be able to create and delete api tokens', async ({ page }) => {
await page.goto('/typebots')
await page.click('text=Settings & Members')
await expect(page.locator('text=Github')).toBeVisible()
await page.click('text="Create"')
await expect(page.locator('button >> text="Create token"')).toBeDisabled()
await page.fill('[placeholder="I.e. Zapier, Github, Make.com"]', 'CLI')
await expect(page.locator('button >> text="Create token"')).toBeEnabled()
await page.click('button >> text="Create token"')
await expect(page.locator('text=Please copy your token')).toBeVisible()
await expect(page.locator('button >> text="Copy"')).toBeVisible()
await page.click('button >> text="Done"')
await expect(page.locator('text=CLI')).toBeVisible()
await page.click('text="Delete" >> nth=2')
await expect(page.locator('strong >> text="Github"')).toBeVisible()
await page.click('button >> text="Delete" >> nth=-1')
await expect(page.locator('button >> text="Delete" >> nth=-1')).toBeEnabled()
await expect(page.locator('text="Github"')).toBeHidden()
})

View File

@ -80,8 +80,6 @@ test.describe('Dashboard page', () => {
})
test("create folder shouldn't be available", async ({ page }) => {
await page.goto('/typebots')
await page.click('text=Shared workspace')
await page.click('text=Free workspace')
await page.click('text=Create a folder')
await expect(page.locator('text=For solo creator')).toBeVisible()
})

View File

@ -0,0 +1,60 @@
import { ApiToken } from 'db'
import { fetcher } from 'services/utils'
import useSWR, { KeyedMutator } from 'swr'
import { isNotEmpty, sendRequest } from 'utils'
export type ApiTokenFromServer = { id: string; name: string; createdAt: string }
type ReturnedProps = {
apiTokens?: ApiTokenFromServer[]
isLoading: boolean
mutate: KeyedMutator<ServerResponse>
}
type ServerResponse = {
apiTokens: ApiTokenFromServer[]
}
export const useApiTokens = ({
userId,
onError,
}: {
userId?: string
onError: (error: Error) => void
}): ReturnedProps => {
const { data, error, mutate } = useSWR<ServerResponse, Error>(
userId ? `/api/users/${userId}/api-tokens` : null,
fetcher,
{
dedupingInterval: isNotEmpty(process.env.NEXT_PUBLIC_E2E_TEST)
? 0
: undefined,
}
)
if (error) onError(error)
return {
apiTokens: data?.apiTokens,
isLoading: !error && !data,
mutate,
}
}
export const createApiToken = (userId: string, { name }: { name: string }) =>
sendRequest<{ apiToken: ApiTokenFromServer & { token: string } }>({
url: `/api/users/${userId}/api-tokens`,
method: 'POST',
body: {
name,
},
})
export const deleteApiToken = ({
userId,
tokenId,
}: {
userId: string
tokenId: string
}) =>
sendRequest<{ apiToken: ApiToken }>({
url: `/api/users/${userId}/api-tokens/${tokenId}`,
method: 'DELETE',
})

View File

@ -123,17 +123,17 @@ export const timeSince = (date: string) => {
}
interval = seconds / 86400
if (interval > 1) {
return Math.floor(interval) + ' days'
return Math.floor(interval) + 'd'
}
interval = seconds / 3600
if (interval > 1) {
return Math.floor(interval) + ' hours'
return Math.floor(interval) + 'h'
}
interval = seconds / 60
if (interval > 1) {
return Math.floor(interval) + ' minutes'
return Math.floor(interval) + 'm'
}
return Math.floor(seconds) + ' seconds'
return Math.floor(seconds) + 's'
}
export const isCloudProdInstance = () =>