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,
|
Tooltip,
|
||||||
Flex,
|
Flex,
|
||||||
Text,
|
Text,
|
||||||
InputRightElement,
|
|
||||||
InputGroup,
|
|
||||||
} from '@chakra-ui/react'
|
} from '@chakra-ui/react'
|
||||||
import { UploadIcon } from 'assets/icons'
|
import { UploadIcon } from 'assets/icons'
|
||||||
import { UploadButton } from 'components/shared/buttons/UploadButton'
|
import { UploadButton } from 'components/shared/buttons/UploadButton'
|
||||||
import { useUser } from 'contexts/UserContext'
|
import { useUser } from 'contexts/UserContext'
|
||||||
import React, { ChangeEvent, useState } from 'react'
|
import React, { ChangeEvent, useState } from 'react'
|
||||||
import { isDefined } from 'utils'
|
import { isDefined } from 'utils'
|
||||||
|
import { ApiTokensList } from './ApiTokensList'
|
||||||
|
|
||||||
export const MyAccountForm = () => {
|
export const MyAccountForm = () => {
|
||||||
const {
|
const {
|
||||||
@@ -28,7 +27,6 @@ export const MyAccountForm = () => {
|
|||||||
isOAuthProvider,
|
isOAuthProvider,
|
||||||
} = useUser()
|
} = useUser()
|
||||||
const [reloadParam, setReloadParam] = useState('')
|
const [reloadParam, setReloadParam] = useState('')
|
||||||
const [isApiTokenVisible, setIsApiTokenVisible] = useState(false)
|
|
||||||
|
|
||||||
const handleFileUploaded = async (url: string) => {
|
const handleFileUploaded = async (url: string) => {
|
||||||
setReloadParam(Date.now().toString())
|
setReloadParam(Date.now().toString())
|
||||||
@@ -43,10 +41,8 @@ export const MyAccountForm = () => {
|
|||||||
updateUser({ email: e.target.value })
|
updateUser({ email: e.target.value })
|
||||||
}
|
}
|
||||||
|
|
||||||
const toggleTokenVisibility = () => setIsApiTokenVisible(!isApiTokenVisible)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack spacing="6" w="full">
|
<Stack spacing="6" w="full" overflowY="scroll">
|
||||||
<HStack spacing={6}>
|
<HStack spacing={6}>
|
||||||
<Avatar
|
<Avatar
|
||||||
size="lg"
|
size="lg"
|
||||||
@@ -95,21 +91,6 @@ export const MyAccountForm = () => {
|
|||||||
</FormControl>
|
</FormControl>
|
||||||
</Tooltip>
|
</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 && (
|
{hasUnsavedChanges && (
|
||||||
<Flex justifyContent="flex-end">
|
<Flex justifyContent="flex-end">
|
||||||
@@ -122,6 +103,8 @@ export const MyAccountForm = () => {
|
|||||||
</Button>
|
</Button>
|
||||||
</Flex>
|
</Flex>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{user && <ApiTokensList user={user} />}
|
||||||
</Stack>
|
</Stack>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { MyAccountForm } from './MyAccountForm'
|
||||||
@@ -131,9 +131,11 @@ export const WorkspaceSettingsModal = ({
|
|||||||
)}
|
)}
|
||||||
</Stack>
|
</Stack>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
{isOpen && (
|
||||||
<Flex flex="1" p="10">
|
<Flex flex="1" p="10">
|
||||||
<SettingsContent tab={selectedTab} onClose={onClose} />
|
<SettingsContent tab={selectedTab} onClose={onClose} />
|
||||||
</Flex>
|
</Flex>
|
||||||
|
)}
|
||||||
</ModalContent>
|
</ModalContent>
|
||||||
</Modal>
|
</Modal>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -7,10 +7,10 @@ import {
|
|||||||
WorkspaceRole,
|
WorkspaceRole,
|
||||||
WorkspaceInvitation,
|
WorkspaceInvitation,
|
||||||
} from 'db'
|
} from 'db'
|
||||||
import { randomUUID } from 'crypto'
|
|
||||||
import type { Adapter, AdapterUser } from 'next-auth/adapters'
|
import type { Adapter, AdapterUser } from 'next-auth/adapters'
|
||||||
import cuid from 'cuid'
|
import cuid from 'cuid'
|
||||||
import { got } from 'got'
|
import { got } from 'got'
|
||||||
|
import { generateId } from 'utils'
|
||||||
|
|
||||||
type InvitationWithWorkspaceId = Invitation & {
|
type InvitationWithWorkspaceId = Invitation & {
|
||||||
typebot: {
|
typebot: {
|
||||||
@@ -38,7 +38,9 @@ export function CustomAdapter(p: PrismaClient): Adapter {
|
|||||||
data: {
|
data: {
|
||||||
...data,
|
...data,
|
||||||
id: user.id,
|
id: user.id,
|
||||||
apiToken: randomUUID(),
|
apiTokens: {
|
||||||
|
create: { name: 'Default', token: generateId(24) },
|
||||||
|
},
|
||||||
workspaces:
|
workspaces:
|
||||||
workspaceInvitations.length > 0
|
workspaceInvitations.length > 0
|
||||||
? undefined
|
? undefined
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
|
|||||||
const user = await getAuthenticatedUser(req)
|
const user = await getAuthenticatedUser(req)
|
||||||
if (!user) return notAuthenticated(res)
|
if (!user) return notAuthenticated(res)
|
||||||
|
|
||||||
const id = req.query.id.toString()
|
const id = req.query.userId.toString()
|
||||||
if (req.method === 'PUT') {
|
if (req.method === 'PUT') {
|
||||||
const data = typeof req.body === 'string' ? JSON.parse(req.body) : req.body
|
const data = typeof req.body === 'string' ? JSON.parse(req.body) : req.body
|
||||||
const typebots = await prisma.user.update({
|
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',
|
email: 'pro-user@email.com',
|
||||||
name: 'Pro user',
|
name: 'Pro user',
|
||||||
graphNavigation: GraphNavigation.TRACKPAD,
|
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: {
|
workspaces: {
|
||||||
create: {
|
create: {
|
||||||
role: WorkspaceRole.ADMIN,
|
role: WorkspaceRole.ADMIN,
|
||||||
@@ -99,7 +120,7 @@ export const createUsers = async () => {
|
|||||||
await prisma.workspace.create({
|
await prisma.workspace.create({
|
||||||
data: {
|
data: {
|
||||||
id: freeWorkspaceId,
|
id: freeWorkspaceId,
|
||||||
name: 'Free Shared Workspace',
|
name: 'Free Shared workspace',
|
||||||
plan: Plan.FREE,
|
plan: Plan.FREE,
|
||||||
members: {
|
members: {
|
||||||
createMany: {
|
createMany: {
|
||||||
@@ -114,7 +135,7 @@ export const createUsers = async () => {
|
|||||||
return prisma.workspace.create({
|
return prisma.workspace.create({
|
||||||
data: {
|
data: {
|
||||||
id: sharedWorkspaceId,
|
id: sharedWorkspaceId,
|
||||||
name: 'Shared Workspace',
|
name: 'Shared workspace',
|
||||||
plan: Plan.TEAM,
|
plan: Plan.TEAM,
|
||||||
members: {
|
members: {
|
||||||
createMany: {
|
createMany: {
|
||||||
|
|||||||
@@ -26,3 +26,23 @@ test('should display user info properly', async ({ page }) => {
|
|||||||
await page.click('text="Preferences"')
|
await page.click('text="Preferences"')
|
||||||
await expect(page.locator('text=Trackpad')).toBeVisible()
|
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 }) => {
|
test("create folder shouldn't be available", async ({ page }) => {
|
||||||
await page.goto('/typebots')
|
await page.goto('/typebots')
|
||||||
await page.click('text=Shared workspace')
|
|
||||||
await page.click('text=Free workspace')
|
|
||||||
await page.click('text=Create a folder')
|
await page.click('text=Create a folder')
|
||||||
await expect(page.locator('text=For solo creator')).toBeVisible()
|
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
|
interval = seconds / 86400
|
||||||
if (interval > 1) {
|
if (interval > 1) {
|
||||||
return Math.floor(interval) + ' days'
|
return Math.floor(interval) + 'd'
|
||||||
}
|
}
|
||||||
interval = seconds / 3600
|
interval = seconds / 3600
|
||||||
if (interval > 1) {
|
if (interval > 1) {
|
||||||
return Math.floor(interval) + ' hours'
|
return Math.floor(interval) + 'h'
|
||||||
}
|
}
|
||||||
interval = seconds / 60
|
interval = seconds / 60
|
||||||
if (interval > 1) {
|
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 = () =>
|
export const isCloudProdInstance = () =>
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ export const createUser = () =>
|
|||||||
id: 'proUser',
|
id: 'proUser',
|
||||||
email: 'user@email.com',
|
email: 'user@email.com',
|
||||||
name: 'User',
|
name: 'User',
|
||||||
apiToken: 'userToken',
|
apiTokens: { create: { token: 'userToken', name: 'default' } },
|
||||||
workspaces: {
|
workspaces: {
|
||||||
create: {
|
create: {
|
||||||
role: WorkspaceRole.ADMIN,
|
role: WorkspaceRole.ADMIN,
|
||||||
|
|||||||
@@ -13,7 +13,9 @@ const authenticateByToken = async (
|
|||||||
apiToken?: string
|
apiToken?: string
|
||||||
): Promise<User | undefined> => {
|
): Promise<User | undefined> => {
|
||||||
if (!apiToken) return
|
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) =>
|
const extractBearerToken = (req: NextApiRequest) =>
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "ApiToken" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"token" TEXT NOT NULL,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"ownerId" TEXT NOT NULL,
|
||||||
|
"lastUsedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "ApiToken_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "ApiToken_token_key" ON "ApiToken"("token");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "ApiToken" ADD CONSTRAINT "ApiToken_ownerId_fkey" FOREIGN KEY ("ownerId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
INSERT INTO "ApiToken" (id, "ownerId", token, name) SELECT uuid_generate_v4(), u.id, u."apiToken", 'Default' FROM "User" u;
|
||||||
|
|
||||||
|
ALTER TABLE "User" DROP COLUMN "apiToken";
|
||||||
@@ -48,7 +48,7 @@ model User {
|
|||||||
image String?
|
image String?
|
||||||
accounts Account[]
|
accounts Account[]
|
||||||
sessions Session[]
|
sessions Session[]
|
||||||
apiToken String?
|
apiTokens ApiToken[]
|
||||||
CollaboratorsOnTypebots CollaboratorsOnTypebots[]
|
CollaboratorsOnTypebots CollaboratorsOnTypebots[]
|
||||||
company String?
|
company String?
|
||||||
onboardingCategories String[]
|
onboardingCategories String[]
|
||||||
@@ -56,6 +56,16 @@ model User {
|
|||||||
workspaces MemberInWorkspace[]
|
workspaces MemberInWorkspace[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model ApiToken {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
token String @unique
|
||||||
|
name String
|
||||||
|
ownerId String
|
||||||
|
owner User @relation(fields: [ownerId], references: [id], onDelete: Cascade)
|
||||||
|
lastUsedAt DateTime @default(now())
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
}
|
||||||
|
|
||||||
model Workspace {
|
model Workspace {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
name String
|
name String
|
||||||
|
|||||||
@@ -177,3 +177,17 @@ export const toTitleCase = (str: string) =>
|
|||||||
/\w\S*/g,
|
/\w\S*/g,
|
||||||
(txt) => txt.charAt(0).toUpperCase() + txt.slice(1).toLowerCase()
|
(txt) => txt.charAt(0).toUpperCase() + txt.slice(1).toLowerCase()
|
||||||
)
|
)
|
||||||
|
|
||||||
|
export const generateId = (idDesiredLength: number): string => {
|
||||||
|
const getRandomCharFromAlphabet = (alphabet: string): string => {
|
||||||
|
return alphabet.charAt(Math.floor(Math.random() * alphabet.length))
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from({ length: idDesiredLength })
|
||||||
|
.map(() => {
|
||||||
|
return getRandomCharFromAlphabet(
|
||||||
|
'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.join('')
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user