♻️ (builder) Change to features-centric folder structure

This commit is contained in:
Baptiste Arnaud
2022-11-15 09:35:48 +01:00
committed by Baptiste Arnaud
parent 3686465a85
commit 643571fe7d
683 changed files with 3907 additions and 3643 deletions

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

View 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()
})

View File

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

View File

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

View File

@@ -0,0 +1 @@
export { ApiTokensList } from './ApiTokensList'

View File

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

View File

@@ -0,0 +1 @@
export { MyAccountForm } from './MyAccountForm'

View 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,
}
}

View File

@@ -0,0 +1,3 @@
export { UserProvider, useUser } from './UserProvider'
export type { ApiTokenFromServer } from './types'
export { MyAccountForm } from './components/MyAccountForm'

View File

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

View File

@@ -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',
})

View File

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

View File

@@ -0,0 +1 @@
export type ApiTokenFromServer = { id: string; name: string; createdAt: string }