♻️ (builder) Change to features-centric folder structure
This commit is contained in:
committed by
Baptiste Arnaud
parent
3686465a85
commit
643571fe7d
121
apps/builder/src/features/account/UserProvider.tsx
Normal file
121
apps/builder/src/features/account/UserProvider.tsx
Normal file
@@ -0,0 +1,121 @@
|
||||
import { useSession } from 'next-auth/react'
|
||||
import { useRouter } from 'next/router'
|
||||
import {
|
||||
createContext,
|
||||
ReactNode,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from 'react'
|
||||
import { isDefined, isNotDefined } from 'utils'
|
||||
import { dequal } from 'dequal'
|
||||
import { User } from 'db'
|
||||
import { setUser as setSentryUser } from '@sentry/nextjs'
|
||||
import { useToast } from '@/hooks/useToast'
|
||||
import { updateUserQuery } from './queries/updateUserQuery'
|
||||
|
||||
const userContext = createContext<{
|
||||
user?: User
|
||||
isLoading: boolean
|
||||
isSaving: boolean
|
||||
hasUnsavedChanges: boolean
|
||||
isOAuthProvider: boolean
|
||||
currentWorkspaceId?: string
|
||||
updateUser: (newUser: Partial<User>) => void
|
||||
saveUser: (newUser?: Partial<User>) => Promise<void>
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
//@ts-ignore
|
||||
}>({})
|
||||
|
||||
export const UserProvider = ({ children }: { children: ReactNode }) => {
|
||||
const router = useRouter()
|
||||
const { data: session, status } = useSession()
|
||||
const [user, setUser] = useState<User | undefined>()
|
||||
const { showToast } = useToast()
|
||||
const [currentWorkspaceId, setCurrentWorkspaceId] = useState<string>()
|
||||
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
const isOAuthProvider = useMemo(
|
||||
() => (session?.providerType as boolean | undefined) ?? false,
|
||||
[session?.providerType]
|
||||
)
|
||||
|
||||
const hasUnsavedChanges = useMemo(
|
||||
() => !dequal(session?.user, user),
|
||||
[session?.user, user]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (isDefined(user) || isNotDefined(session)) return
|
||||
setCurrentWorkspaceId(
|
||||
localStorage.getItem('currentWorkspaceId') ?? undefined
|
||||
)
|
||||
const parsedUser = session.user as User
|
||||
setUser(parsedUser)
|
||||
if (parsedUser?.id) setSentryUser({ id: parsedUser.id })
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [session])
|
||||
|
||||
useEffect(() => {
|
||||
if (!router.isReady) return
|
||||
if (status === 'loading') return
|
||||
if (!user && status === 'unauthenticated' && !isSigningIn())
|
||||
router.replace({
|
||||
pathname: '/signin',
|
||||
query:
|
||||
router.pathname !== '/typebots'
|
||||
? {
|
||||
redirectPath: router.asPath,
|
||||
}
|
||||
: undefined,
|
||||
})
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [status, router])
|
||||
|
||||
const isSigningIn = () => ['/signin', '/register'].includes(router.pathname)
|
||||
|
||||
const updateUser = (newUser: Partial<User>) => {
|
||||
if (isNotDefined(user)) return
|
||||
setUser({ ...user, ...newUser })
|
||||
}
|
||||
|
||||
const saveUser = async (newUser?: Partial<User>) => {
|
||||
if (isNotDefined(user)) return
|
||||
setIsSaving(true)
|
||||
if (newUser) updateUser(newUser)
|
||||
const { error } = await updateUserQuery(user.id, { ...user, ...newUser })
|
||||
if (error) showToast({ title: error.name, description: error.message })
|
||||
await refreshUser()
|
||||
setIsSaving(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<userContext.Provider
|
||||
value={{
|
||||
user,
|
||||
isSaving,
|
||||
isLoading: status === 'loading',
|
||||
hasUnsavedChanges,
|
||||
isOAuthProvider,
|
||||
currentWorkspaceId,
|
||||
updateUser,
|
||||
saveUser,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</userContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export const refreshUser = async () => {
|
||||
await fetch('/api/auth/session?update')
|
||||
reloadSession()
|
||||
}
|
||||
|
||||
const reloadSession = () => {
|
||||
const event = new Event('visibilitychange')
|
||||
document.dispatchEvent(event)
|
||||
}
|
||||
|
||||
export const useUser = () => useContext(userContext)
|
||||
49
apps/builder/src/features/account/account.spec.ts
Normal file
49
apps/builder/src/features/account/account.spec.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { getTestAsset } from '@/test/utils/playwright'
|
||||
import test, { expect } from '@playwright/test'
|
||||
import { userId } from 'utils/playwright/databaseSetup'
|
||||
|
||||
test.describe.configure({ mode: 'parallel' })
|
||||
|
||||
test('should display user info properly', async ({ page }) => {
|
||||
await page.goto('/typebots')
|
||||
await page.click('text=Settings & Members')
|
||||
const saveButton = page.locator('button:has-text("Save")')
|
||||
await expect(saveButton).toBeHidden()
|
||||
expect(
|
||||
page.locator('input[type="email"]').getAttribute('disabled')
|
||||
).toBeDefined()
|
||||
await page.fill('#name', 'John Doe')
|
||||
expect(saveButton).toBeVisible()
|
||||
await page.setInputFiles('input[type="file"]', getTestAsset('avatar.jpg'))
|
||||
await expect(page.locator('img >> nth=1')).toHaveAttribute(
|
||||
'src',
|
||||
new RegExp(
|
||||
`${process.env.S3_ENDPOINT}${
|
||||
process.env.S3_PORT ? `:${process.env.S3_PORT}` : ''
|
||||
}/${process.env.S3_BUCKET}/public/users/${userId}/avatar`,
|
||||
'gm'
|
||||
)
|
||||
)
|
||||
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()
|
||||
})
|
||||
@@ -0,0 +1,130 @@
|
||||
import {
|
||||
TableContainer,
|
||||
Table,
|
||||
Thead,
|
||||
Tr,
|
||||
Th,
|
||||
Tbody,
|
||||
Td,
|
||||
Button,
|
||||
Text,
|
||||
Heading,
|
||||
Checkbox,
|
||||
Skeleton,
|
||||
Stack,
|
||||
Flex,
|
||||
useDisclosure,
|
||||
} from '@chakra-ui/react'
|
||||
import { ConfirmModal } from '@/components/ConfirmModal'
|
||||
import { useToast } from '@/hooks/useToast'
|
||||
import { User } from 'db'
|
||||
import React, { useState } from 'react'
|
||||
import { byId, isDefined } from 'utils'
|
||||
import { CreateTokenModal } from './CreateTokenModal'
|
||||
import { useApiTokens } from '../../../hooks/useApiTokens'
|
||||
import { ApiTokenFromServer } from '../../../types'
|
||||
import { timeSince } from '@/utils/helpers'
|
||||
import { deleteApiTokenQuery } from '../../../queries/deleteApiTokenQuery'
|
||||
|
||||
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 deleteApiTokenQuery({ 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,101 @@
|
||||
import { CopyButton } from '@/components/CopyButton'
|
||||
import { createApiTokenQuery } from '../../../queries/createApiTokenQuery'
|
||||
import { ApiTokenFromServer } from '../../../types'
|
||||
import {
|
||||
Modal,
|
||||
ModalOverlay,
|
||||
ModalContent,
|
||||
ModalHeader,
|
||||
ModalCloseButton,
|
||||
ModalBody,
|
||||
Stack,
|
||||
Input,
|
||||
ModalFooter,
|
||||
Button,
|
||||
Text,
|
||||
InputGroup,
|
||||
InputRightElement,
|
||||
} from '@chakra-ui/react'
|
||||
import React, { FormEvent, useState } from 'react'
|
||||
|
||||
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 createApiTokenQuery(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'
|
||||
@@ -0,0 +1,110 @@
|
||||
import {
|
||||
Stack,
|
||||
HStack,
|
||||
Avatar,
|
||||
Button,
|
||||
FormControl,
|
||||
FormLabel,
|
||||
Input,
|
||||
Tooltip,
|
||||
Flex,
|
||||
Text,
|
||||
} from '@chakra-ui/react'
|
||||
import { UploadIcon } from '@/components/icons'
|
||||
import React, { ChangeEvent, useState } from 'react'
|
||||
import { isDefined } from 'utils'
|
||||
import { ApiTokensList } from './ApiTokensList'
|
||||
import { UploadButton } from '@/components/ImageUploadContent/UploadButton'
|
||||
import { useUser } from '@/features/account'
|
||||
|
||||
export const MyAccountForm = () => {
|
||||
const {
|
||||
user,
|
||||
updateUser,
|
||||
saveUser,
|
||||
hasUnsavedChanges,
|
||||
isSaving,
|
||||
isOAuthProvider,
|
||||
} = useUser()
|
||||
const [reloadParam, setReloadParam] = useState('')
|
||||
|
||||
const handleFileUploaded = async (url: string) => {
|
||||
setReloadParam(Date.now().toString())
|
||||
updateUser({ image: url })
|
||||
}
|
||||
|
||||
const handleNameChange = (e: ChangeEvent<HTMLInputElement>) => {
|
||||
updateUser({ name: e.target.value })
|
||||
}
|
||||
|
||||
const handleEmailChange = (e: ChangeEvent<HTMLInputElement>) => {
|
||||
updateUser({ email: e.target.value })
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack spacing="6" w="full" overflowY="scroll">
|
||||
<HStack spacing={6}>
|
||||
<Avatar
|
||||
size="lg"
|
||||
src={user?.image ? `${user.image}?${reloadParam}` : undefined}
|
||||
name={user?.name ?? undefined}
|
||||
/>
|
||||
<Stack>
|
||||
<UploadButton
|
||||
size="sm"
|
||||
filePath={`users/${user?.id}/avatar`}
|
||||
leftIcon={<UploadIcon />}
|
||||
onFileUploaded={handleFileUploaded}
|
||||
>
|
||||
Change photo
|
||||
</UploadButton>
|
||||
<Text color="gray.500" fontSize="sm">
|
||||
.jpg or.png, max 1MB
|
||||
</Text>
|
||||
</Stack>
|
||||
</HStack>
|
||||
|
||||
<FormControl>
|
||||
<FormLabel htmlFor="name">Name</FormLabel>
|
||||
<Input id="name" value={user?.name ?? ''} onChange={handleNameChange} />
|
||||
</FormControl>
|
||||
{isDefined(user?.email) && (
|
||||
<Tooltip
|
||||
label="Updating email is not available."
|
||||
placement="left"
|
||||
hasArrow
|
||||
>
|
||||
<FormControl>
|
||||
<FormLabel
|
||||
htmlFor="email"
|
||||
color={isOAuthProvider ? 'gray.500' : 'current'}
|
||||
>
|
||||
Email address
|
||||
</FormLabel>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
isDisabled
|
||||
value={user?.email ?? ''}
|
||||
onChange={handleEmailChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{hasUnsavedChanges && (
|
||||
<Flex justifyContent="flex-end">
|
||||
<Button
|
||||
colorScheme="blue"
|
||||
onClick={() => saveUser()}
|
||||
isLoading={isSaving}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</Flex>
|
||||
)}
|
||||
|
||||
{user && <ApiTokensList user={user} />}
|
||||
</Stack>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { MyAccountForm } from './MyAccountForm'
|
||||
30
apps/builder/src/features/account/hooks/useApiTokens.ts
Normal file
30
apps/builder/src/features/account/hooks/useApiTokens.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { fetcher } from '@/utils/helpers'
|
||||
import useSWR from 'swr'
|
||||
import { env } from 'utils'
|
||||
import { ApiTokenFromServer } from '../types'
|
||||
|
||||
type ServerResponse = {
|
||||
apiTokens: ApiTokenFromServer[]
|
||||
}
|
||||
|
||||
export const useApiTokens = ({
|
||||
userId,
|
||||
onError,
|
||||
}: {
|
||||
userId?: string
|
||||
onError: (error: Error) => void
|
||||
}) => {
|
||||
const { data, error, mutate } = useSWR<ServerResponse, Error>(
|
||||
userId ? `/api/users/${userId}/api-tokens` : null,
|
||||
fetcher,
|
||||
{
|
||||
dedupingInterval: env('E2E_TEST') === 'true' ? 0 : undefined,
|
||||
}
|
||||
)
|
||||
if (error) onError(error)
|
||||
return {
|
||||
apiTokens: data?.apiTokens,
|
||||
isLoading: !error && !data,
|
||||
mutate,
|
||||
}
|
||||
}
|
||||
3
apps/builder/src/features/account/index.ts
Normal file
3
apps/builder/src/features/account/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { UserProvider, useUser } from './UserProvider'
|
||||
export type { ApiTokenFromServer } from './types'
|
||||
export { MyAccountForm } from './components/MyAccountForm'
|
||||
@@ -0,0 +1,14 @@
|
||||
import { sendRequest } from 'utils'
|
||||
import { ApiTokenFromServer } from '../types'
|
||||
|
||||
export const createApiTokenQuery = (
|
||||
userId: string,
|
||||
{ name }: { name: string }
|
||||
) =>
|
||||
sendRequest<{ apiToken: ApiTokenFromServer & { token: string } }>({
|
||||
url: `/api/users/${userId}/api-tokens`,
|
||||
method: 'POST',
|
||||
body: {
|
||||
name,
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,14 @@
|
||||
import { ApiToken } from 'db'
|
||||
import { sendRequest } from 'utils'
|
||||
|
||||
export const deleteApiTokenQuery = ({
|
||||
userId,
|
||||
tokenId,
|
||||
}: {
|
||||
userId: string
|
||||
tokenId: string
|
||||
}) =>
|
||||
sendRequest<{ apiToken: ApiToken }>({
|
||||
url: `/api/users/${userId}/api-tokens/${tokenId}`,
|
||||
method: 'DELETE',
|
||||
})
|
||||
@@ -0,0 +1,9 @@
|
||||
import { User } from 'db'
|
||||
import { sendRequest } from 'utils'
|
||||
|
||||
export const updateUserQuery = async (id: string, user: User) =>
|
||||
sendRequest({
|
||||
url: `/api/users/${id}`,
|
||||
method: 'PUT',
|
||||
body: user,
|
||||
})
|
||||
1
apps/builder/src/features/account/types.ts
Normal file
1
apps/builder/src/features/account/types.ts
Normal file
@@ -0,0 +1 @@
|
||||
export type ApiTokenFromServer = { id: string; name: string; createdAt: string }
|
||||
Reference in New Issue
Block a user