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>
|
||||
)
|
||||
|
@ -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
|
||||
|
@ -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({
|
39
apps/builder/pages/api/users/[userId]/api-tokens.ts
Normal file
39
apps/builder/pages/api/users/[userId]/api-tokens.ts
Normal 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)
|
@ -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)
|
@ -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: {
|
||||
|
@ -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()
|
||||
})
|
||||
|
@ -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()
|
||||
})
|
||||
|
60
apps/builder/services/user/apiTokens.ts
Normal file
60
apps/builder/services/user/apiTokens.ts
Normal 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',
|
||||
})
|
@ -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 = () =>
|
||||
|
@ -38,7 +38,7 @@ export const createUser = () =>
|
||||
id: 'proUser',
|
||||
email: 'user@email.com',
|
||||
name: 'User',
|
||||
apiToken: 'userToken',
|
||||
apiTokens: { create: { token: 'userToken', name: 'default' } },
|
||||
workspaces: {
|
||||
create: {
|
||||
role: WorkspaceRole.ADMIN,
|
||||
|
@ -13,7 +13,9 @@ const authenticateByToken = async (
|
||||
apiToken?: string
|
||||
): Promise<User | undefined> => {
|
||||
if (!apiToken) return
|
||||
return (await prisma.user.findFirst({ where: { apiToken } })) as User
|
||||
return (await prisma.user.findFirst({
|
||||
where: { apiTokens: { some: { token: apiToken } } },
|
||||
})) as User
|
||||
}
|
||||
|
||||
const extractBearerToken = (req: NextApiRequest) =>
|
||||
|
Reference in New Issue
Block a user