feat: ✨ Add collaboration
This commit is contained in:
36
apps/builder/assets/emails/invitationToCollaborate.mjml
Normal file
36
apps/builder/assets/emails/invitationToCollaborate.mjml
Normal file
@ -0,0 +1,36 @@
|
||||
<mjml>
|
||||
<mj-head>
|
||||
<mj-attributes>
|
||||
<mj-all font-family="Helvetica Neue, Helvetica, Helvetica, Arial, sans-serif" font-size="16px" padding="0" line-height="23px"></mj-all>
|
||||
<mj-section background-color="#ffffff" padding-bottom="20px"></mj-section>
|
||||
<mj-text padding="10px 40px"></mj-text>
|
||||
</mj-attributes>
|
||||
<mj-style inline="inline">
|
||||
.footer-link {
|
||||
color: #A0AEC0
|
||||
}
|
||||
</mj-style>
|
||||
</mj-head>
|
||||
<mj-body background-color="#ffffff">
|
||||
<mj-wrapper border="1px solid #E2E8F0">
|
||||
<mj-section padding-bottom="0px">
|
||||
<mj-column width="100%">
|
||||
<mj-image src="https://typebot.s3.eu-west-3.amazonaws.com/assets/invitation-banner.png" alt="header image" padding="0px"></mj-image>
|
||||
</mj-column>
|
||||
</mj-section>
|
||||
<mj-section padding-top="20px">
|
||||
<mj-column>
|
||||
<mj-text>You have been invited to collaborate on a typebot created by ${email}</mj-text>
|
||||
<mj-text>From now on you will see this typebot in your dashboard under the "Shared with me "button 👍</mj-text>
|
||||
</mj-column>
|
||||
</mj-section>
|
||||
<mj-section>
|
||||
<mj-column>
|
||||
<mj-button background-color="#0042da" color="white" href="${url}" font-weight="500" border-radius="5px">See the typebot</mj-button>
|
||||
</mj-column>
|
||||
</mj-section>
|
||||
|
||||
</mj-wrapper>
|
||||
|
||||
</mj-body>
|
||||
</mjml>
|
506
apps/builder/assets/emails/invitationToCollaborate.ts
Normal file
506
apps/builder/assets/emails/invitationToCollaborate.ts
Normal file
@ -0,0 +1,506 @@
|
||||
export const invitationToCollaborate = (
|
||||
email: string,
|
||||
typebotUrl: string
|
||||
) => `<!DOCTYPE html>
|
||||
<html
|
||||
xmlns="http://www.w3.org/1999/xhtml"
|
||||
xmlns:v="urn:schemas-microsoft-com:vml"
|
||||
xmlns:o="urn:schemas-microsoft-com:office:office"
|
||||
>
|
||||
<head>
|
||||
<title></title>
|
||||
<!--[if !mso]><!-->
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
|
||||
<!--<![endif]-->
|
||||
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||
<style type="text/css">
|
||||
#outlook a {
|
||||
padding: 0;
|
||||
}
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
-webkit-text-size-adjust: 100%;
|
||||
-ms-text-size-adjust: 100%;
|
||||
}
|
||||
table,
|
||||
td {
|
||||
border-collapse: collapse;
|
||||
mso-table-lspace: 0pt;
|
||||
mso-table-rspace: 0pt;
|
||||
}
|
||||
img {
|
||||
border: 0;
|
||||
height: auto;
|
||||
line-height: 100%;
|
||||
outline: none;
|
||||
text-decoration: none;
|
||||
-ms-interpolation-mode: bicubic;
|
||||
}
|
||||
p {
|
||||
display: block;
|
||||
margin: 13px 0;
|
||||
}
|
||||
</style>
|
||||
<!--[if mso]>
|
||||
<noscript>
|
||||
<xml>
|
||||
<o:OfficeDocumentSettings>
|
||||
<o:AllowPNG />
|
||||
<o:PixelsPerInch>96</o:PixelsPerInch>
|
||||
</o:OfficeDocumentSettings>
|
||||
</xml>
|
||||
</noscript>
|
||||
<![endif]-->
|
||||
<!--[if lte mso 11]>
|
||||
<style type="text/css">
|
||||
.mj-outlook-group-fix {
|
||||
width: 100% !important;
|
||||
}
|
||||
</style>
|
||||
<![endif]-->
|
||||
<style type="text/css">
|
||||
@media only screen and (min-width: 480px) {
|
||||
.mj-column-per-100 {
|
||||
width: 100% !important;
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<style media="screen and (min-width:480px)">
|
||||
.moz-text-html .mj-column-per-100 {
|
||||
width: 100% !important;
|
||||
max-width: 100%;
|
||||
}
|
||||
</style>
|
||||
<style type="text/css">
|
||||
@media only screen and (max-width: 480px) {
|
||||
table.mj-full-width-mobile {
|
||||
width: 100% !important;
|
||||
}
|
||||
td.mj-full-width-mobile {
|
||||
width: auto !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body style="word-spacing: normal; background-color: #ffffff">
|
||||
<div style="background-color: #ffffff">
|
||||
<!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:600px;" width="600" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
|
||||
<div style="margin: 0px auto; max-width: 600px">
|
||||
<table
|
||||
align="center"
|
||||
border="0"
|
||||
cellpadding="0"
|
||||
cellspacing="0"
|
||||
role="presentation"
|
||||
style="width: 100%"
|
||||
>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td
|
||||
style="
|
||||
border: 1px solid #e2e8f0;
|
||||
direction: ltr;
|
||||
font-size: 0px;
|
||||
padding: 0;
|
||||
text-align: center;
|
||||
"
|
||||
>
|
||||
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" width="600px" ><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:598px;" width="598" bgcolor="#ffffff" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
|
||||
<div
|
||||
style="
|
||||
background: #ffffff;
|
||||
background-color: #ffffff;
|
||||
margin: 0px auto;
|
||||
max-width: 598px;
|
||||
"
|
||||
>
|
||||
<table
|
||||
align="center"
|
||||
border="0"
|
||||
cellpadding="0"
|
||||
cellspacing="0"
|
||||
role="presentation"
|
||||
style="
|
||||
background: #ffffff;
|
||||
background-color: #ffffff;
|
||||
width: 100%;
|
||||
"
|
||||
>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td
|
||||
style="
|
||||
direction: ltr;
|
||||
font-size: 0px;
|
||||
padding: 0;
|
||||
padding-bottom: 0px;
|
||||
text-align: center;
|
||||
"
|
||||
>
|
||||
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:598px;" ><![endif]-->
|
||||
<div
|
||||
class="mj-column-per-100 mj-outlook-group-fix"
|
||||
style="
|
||||
font-size: 0px;
|
||||
text-align: left;
|
||||
direction: ltr;
|
||||
display: inline-block;
|
||||
vertical-align: top;
|
||||
width: 100%;
|
||||
"
|
||||
>
|
||||
<table
|
||||
border="0"
|
||||
cellpadding="0"
|
||||
cellspacing="0"
|
||||
role="presentation"
|
||||
width="100%"
|
||||
>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="vertical-align: top; padding: 0">
|
||||
<table
|
||||
border="0"
|
||||
cellpadding="0"
|
||||
cellspacing="0"
|
||||
role="presentation"
|
||||
width="100%"
|
||||
>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td
|
||||
align="center"
|
||||
style="
|
||||
font-size: 0px;
|
||||
padding: 0px;
|
||||
word-break: break-word;
|
||||
"
|
||||
>
|
||||
<table
|
||||
border="0"
|
||||
cellpadding="0"
|
||||
cellspacing="0"
|
||||
role="presentation"
|
||||
style="
|
||||
border-collapse: collapse;
|
||||
border-spacing: 0px;
|
||||
"
|
||||
>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="width: 598px">
|
||||
<img
|
||||
alt="header image"
|
||||
height="auto"
|
||||
src="https://typebot.s3.eu-west-3.amazonaws.com/assets/invitation-banner.png"
|
||||
style="
|
||||
border: 0;
|
||||
display: block;
|
||||
outline: none;
|
||||
text-decoration: none;
|
||||
height: auto;
|
||||
width: 100%;
|
||||
font-size: 16px;
|
||||
"
|
||||
width="598"
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<!--[if mso | IE]></td></tr></table><![endif]-->
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<!--[if mso | IE]></td></tr></table></td></tr><tr><td class="" width="600px" ><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:598px;" width="598" bgcolor="#ffffff" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
|
||||
<div
|
||||
style="
|
||||
background: #ffffff;
|
||||
background-color: #ffffff;
|
||||
margin: 0px auto;
|
||||
max-width: 598px;
|
||||
"
|
||||
>
|
||||
<table
|
||||
align="center"
|
||||
border="0"
|
||||
cellpadding="0"
|
||||
cellspacing="0"
|
||||
role="presentation"
|
||||
style="
|
||||
background: #ffffff;
|
||||
background-color: #ffffff;
|
||||
width: 100%;
|
||||
"
|
||||
>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td
|
||||
style="
|
||||
direction: ltr;
|
||||
font-size: 0px;
|
||||
padding: 0;
|
||||
padding-bottom: 20px;
|
||||
padding-top: 20px;
|
||||
text-align: center;
|
||||
"
|
||||
>
|
||||
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:598px;" ><![endif]-->
|
||||
<div
|
||||
class="mj-column-per-100 mj-outlook-group-fix"
|
||||
style="
|
||||
font-size: 0px;
|
||||
text-align: left;
|
||||
direction: ltr;
|
||||
display: inline-block;
|
||||
vertical-align: top;
|
||||
width: 100%;
|
||||
"
|
||||
>
|
||||
<table
|
||||
border="0"
|
||||
cellpadding="0"
|
||||
cellspacing="0"
|
||||
role="presentation"
|
||||
width="100%"
|
||||
>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="vertical-align: top; padding: 0">
|
||||
<table
|
||||
border="0"
|
||||
cellpadding="0"
|
||||
cellspacing="0"
|
||||
role="presentation"
|
||||
width="100%"
|
||||
>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td
|
||||
align="left"
|
||||
style="
|
||||
font-size: 0px;
|
||||
padding: 10px 40px;
|
||||
word-break: break-word;
|
||||
"
|
||||
>
|
||||
<div
|
||||
style="
|
||||
font-family: Helvetica Neue,
|
||||
Helvetica, Helvetica, Arial,
|
||||
sans-serif;
|
||||
font-size: 16px;
|
||||
line-height: 23px;
|
||||
text-align: left;
|
||||
color: #000000;
|
||||
"
|
||||
>
|
||||
You have been invited to
|
||||
collaborate on a typebot created
|
||||
by ${email}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td
|
||||
align="left"
|
||||
style="
|
||||
font-size: 0px;
|
||||
padding: 10px 40px;
|
||||
word-break: break-word;
|
||||
"
|
||||
>
|
||||
<div
|
||||
style="
|
||||
font-family: Helvetica Neue,
|
||||
Helvetica, Helvetica, Arial,
|
||||
sans-serif;
|
||||
font-size: 16px;
|
||||
line-height: 23px;
|
||||
text-align: left;
|
||||
color: #000000;
|
||||
"
|
||||
>
|
||||
From now on you will see this
|
||||
typebot in your dashboard under
|
||||
the "Shared with me" button 👍
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<!--[if mso | IE]></td></tr></table><![endif]-->
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<!--[if mso | IE]></td></tr></table></td></tr><tr><td class="" width="600px" ><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:598px;" width="598" bgcolor="#ffffff" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
|
||||
<div
|
||||
style="
|
||||
background: #ffffff;
|
||||
background-color: #ffffff;
|
||||
margin: 0px auto;
|
||||
max-width: 598px;
|
||||
"
|
||||
>
|
||||
<table
|
||||
align="center"
|
||||
border="0"
|
||||
cellpadding="0"
|
||||
cellspacing="0"
|
||||
role="presentation"
|
||||
style="
|
||||
background: #ffffff;
|
||||
background-color: #ffffff;
|
||||
width: 100%;
|
||||
"
|
||||
>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td
|
||||
style="
|
||||
direction: ltr;
|
||||
font-size: 0px;
|
||||
padding: 0;
|
||||
padding-bottom: 20px;
|
||||
text-align: center;
|
||||
"
|
||||
>
|
||||
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:598px;" ><![endif]-->
|
||||
<div
|
||||
class="mj-column-per-100 mj-outlook-group-fix"
|
||||
style="
|
||||
font-size: 0px;
|
||||
text-align: left;
|
||||
direction: ltr;
|
||||
display: inline-block;
|
||||
vertical-align: top;
|
||||
width: 100%;
|
||||
"
|
||||
>
|
||||
<table
|
||||
border="0"
|
||||
cellpadding="0"
|
||||
cellspacing="0"
|
||||
role="presentation"
|
||||
width="100%"
|
||||
>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="vertical-align: top; padding: 0">
|
||||
<table
|
||||
border="0"
|
||||
cellpadding="0"
|
||||
cellspacing="0"
|
||||
role="presentation"
|
||||
width="100%"
|
||||
>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td
|
||||
align="center"
|
||||
vertical-align="middle"
|
||||
style="
|
||||
font-size: 0px;
|
||||
padding: 0;
|
||||
word-break: break-word;
|
||||
"
|
||||
>
|
||||
<table
|
||||
border="0"
|
||||
cellpadding="0"
|
||||
cellspacing="0"
|
||||
role="presentation"
|
||||
style="
|
||||
border-collapse: separate;
|
||||
line-height: 100%;
|
||||
"
|
||||
>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td
|
||||
align="center"
|
||||
bgcolor="#0042da"
|
||||
role="presentation"
|
||||
style="
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
cursor: auto;
|
||||
mso-padding-alt: 10px 25px;
|
||||
background: #0042da;
|
||||
"
|
||||
valign="middle"
|
||||
>
|
||||
<a
|
||||
href="${typebotUrl}"
|
||||
style="
|
||||
display: inline-block;
|
||||
background: #0042da;
|
||||
color: white;
|
||||
font-family: Helvetica
|
||||
Neue,
|
||||
Helvetica, Helvetica,
|
||||
Arial, sans-serif;
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
line-height: 23px;
|
||||
margin: 0;
|
||||
text-decoration: none;
|
||||
text-transform: none;
|
||||
padding: 10px 25px;
|
||||
mso-padding-alt: 0px;
|
||||
border-radius: 5px;
|
||||
"
|
||||
target="_blank"
|
||||
>See the typebot</a
|
||||
>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<!--[if mso | IE]></td></tr></table><![endif]-->
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<!--[if mso | IE]></td></tr></table></td></tr></table><![endif]-->
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<!--[if mso | IE]></td></tr></table><![endif]-->
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`
|
@ -0,0 +1,50 @@
|
||||
<mjml>
|
||||
<mj-head>
|
||||
<mj-attributes>
|
||||
<mj-all font-family="Helvetica Neue, Helvetica, Helvetica, Arial, sans-serif" font-size="16px" padding="0" line-height="23px"></mj-all>
|
||||
<mj-section background-color="#ffffff" padding-bottom="20px"></mj-section>
|
||||
<mj-text padding="10px 40px"></mj-text>
|
||||
</mj-attributes>
|
||||
<mj-style inline="inline">
|
||||
.footer-link {
|
||||
color: #A0AEC0
|
||||
}
|
||||
</mj-style>
|
||||
</mj-head>
|
||||
<mj-body background-color="#ffffff">
|
||||
<mj-wrapper border="1px solid #E2E8F0">
|
||||
<mj-section padding-bottom="0px">
|
||||
<mj-column width="100%">
|
||||
<mj-image src="https://typebot.s3.eu-west-3.amazonaws.com/assets/typebot2-banner.png" alt="header image" padding="0px"></mj-image>
|
||||
</mj-column>
|
||||
</mj-section>
|
||||
<mj-section padding-top="20px">
|
||||
<mj-column>
|
||||
<mj-text>You receive this email because you are the owner of a {{PLAN}} plan on Typebot. Which means you can keep all your perks on Typebot 2.0 as well 😍</mj-text>
|
||||
<mj-text>All you have to do is to Create your new account by clicking on the button below.</mj-text>
|
||||
</mj-column>
|
||||
</mj-section>
|
||||
<mj-section>
|
||||
<mj-column>
|
||||
<mj-button background-color="#0042da" color="white" href="https://app.typebot.io/register" font-weight="500" border-radius="5px">Create my account</mj-button>
|
||||
</mj-column>
|
||||
</mj-section>
|
||||
<mj-section padding-top="20px">
|
||||
<mj-column>
|
||||
<mj-text>Your plan should automatically be applied. If you have any issue, hit reply 😃</mj-text>
|
||||
<mj-text>I'm eagerly waiting for your feedback! Let's make this tool amazing.</mj-text>
|
||||
<mj-text padding-bottom="0px">Baptiste,</mj-text>
|
||||
<mj-text padding-top="0px">Founder of Typebot.</mj-text>
|
||||
</mj-column>
|
||||
</mj-section>
|
||||
<mj-section>
|
||||
<mj-column>
|
||||
<mj-text font-size="13px" text-decoration="underline">
|
||||
<a class="footer-link" href="%unsubscribe_url%" color="#fffff">Unsubscribe from all future emails</a>
|
||||
</mj-text>
|
||||
</mj-column>
|
||||
</mj-section>
|
||||
</mj-wrapper>
|
||||
|
||||
</mj-body>
|
||||
</mjml>
|
@ -353,3 +353,12 @@ export const GithubIcon = (props: IconProps) => (
|
||||
<path d="M256 32C132.3 32 32 134.9 32 261.7c0 101.5 64.2 187.5 153.2 217.9a17.56 17.56 0 003.8.4c8.3 0 11.5-6.1 11.5-11.4 0-5.5-.2-19.9-.3-39.1a102.4 102.4 0 01-22.6 2.7c-43.1 0-52.9-33.5-52.9-33.5-10.2-26.5-24.9-33.6-24.9-33.6-19.5-13.7-.1-14.1 1.4-14.1h.1c22.5 2 34.3 23.8 34.3 23.8 11.2 19.6 26.2 25.1 39.6 25.1a63 63 0 0025.6-6c2-14.8 7.8-24.9 14.2-30.7-49.7-5.8-102-25.5-102-113.5 0-25.1 8.7-45.6 23-61.6-2.3-5.8-10-29.2 2.2-60.8a18.64 18.64 0 015-.5c8.1 0 26.4 3.1 56.6 24.1a208.21 208.21 0 01112.2 0c30.2-21 48.5-24.1 56.6-24.1a18.64 18.64 0 015 .5c12.2 31.6 4.5 55 2.2 60.8 14.3 16.1 23 36.6 23 61.6 0 88.2-52.4 107.6-102.3 113.3 8 7.1 15.2 21.1 15.2 42.5 0 30.7-.3 55.5-.3 63 0 5.4 3.1 11.5 11.4 11.5a19.35 19.35 0 004-.4C415.9 449.2 480 363.1 480 261.7 480 134.9 379.7 32 256 32z" />
|
||||
</Icon>
|
||||
)
|
||||
|
||||
export const UsersIcon = (props: IconProps) => (
|
||||
<Icon viewBox="0 0 24 24" {...featherIconsBaseProps} {...props}>
|
||||
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"></path>
|
||||
<circle cx="9" cy="7" r="4"></circle>
|
||||
<path d="M23 21v-2a4 4 0 0 0-3-3.87"></path>
|
||||
<path d="M16 3.13a4 4 0 0 1 0 7.75"></path>
|
||||
</Icon>
|
||||
)
|
||||
|
@ -10,7 +10,7 @@ export const SubscriptionTag = ({ plan }: { plan?: Plan }) => {
|
||||
return <Tag colorScheme="yellow">Lifetime plan</Tag>
|
||||
}
|
||||
case Plan.OFFERED: {
|
||||
return <Tag>Offered</Tag>
|
||||
return <Tag colorScheme="yellow">Offered</Tag>
|
||||
}
|
||||
case Plan.PRO: {
|
||||
return <Tag colorScheme="orange">Pro plan</Tag>
|
||||
|
@ -12,6 +12,7 @@ import {
|
||||
Wrap,
|
||||
} from '@chakra-ui/react'
|
||||
import { useTypebotDnd } from 'contexts/TypebotDndContext'
|
||||
import { useUser } from 'contexts/UserContext'
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { createFolder, useFolders } from 'services/folders'
|
||||
import {
|
||||
@ -19,11 +20,13 @@ import {
|
||||
TypebotInDashboard,
|
||||
useTypebots,
|
||||
} from 'services/typebots'
|
||||
import { useSharedTypebotsCount } from 'services/user/sharedTypebots'
|
||||
import { AnnoucementModal } from './annoucements/AnnoucementModal'
|
||||
import { BackButton } from './FolderContent/BackButton'
|
||||
import { CreateBotButton } from './FolderContent/CreateBotButton'
|
||||
import { CreateFolderButton } from './FolderContent/CreateFolderButton'
|
||||
import { ButtonSkeleton, FolderButton } from './FolderContent/FolderButton'
|
||||
import { SharedTypebotsButton } from './FolderContent/SharedTypebotsButton'
|
||||
import { TypebotButton } from './FolderContent/TypebotButton'
|
||||
import { TypebotCardOverlay } from './FolderContent/TypebotButtonOverlay'
|
||||
|
||||
@ -32,6 +35,7 @@ type Props = { folder: DashboardFolder | null }
|
||||
const dragDistanceTolerance = 20
|
||||
|
||||
export const FolderContent = ({ folder }: Props) => {
|
||||
const { user } = useUser()
|
||||
const [isCreatingFolder, setIsCreatingFolder] = useState(false)
|
||||
const {
|
||||
setDraggedTypebot,
|
||||
@ -75,6 +79,17 @@ export const FolderContent = ({ folder }: Props) => {
|
||||
},
|
||||
})
|
||||
|
||||
const { totalSharedTypebots, isLoading: isSharedTypebotsCountLoading } =
|
||||
useSharedTypebotsCount({
|
||||
userId: folder === null ? user?.id : undefined,
|
||||
onError: (error) => {
|
||||
toast({
|
||||
title: "Couldn't fetch shared typebots",
|
||||
description: error.message,
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
typebots &&
|
||||
@ -182,6 +197,8 @@ export const FolderContent = ({ folder }: Props) => {
|
||||
folderId={folder?.id}
|
||||
isLoading={isTypebotLoading}
|
||||
/>
|
||||
{isSharedTypebotsCountLoading && <ButtonSkeleton />}
|
||||
{totalSharedTypebots > 0 && <SharedTypebotsButton />}
|
||||
{isFolderLoading && <ButtonSkeleton />}
|
||||
{folders &&
|
||||
folders.map((folder) => (
|
||||
|
@ -0,0 +1,40 @@
|
||||
import React from 'react'
|
||||
import { Button, Flex, Text, VStack, WrapItem } from '@chakra-ui/react'
|
||||
import { useRouter } from 'next/router'
|
||||
import { UsersIcon } from 'assets/icons'
|
||||
|
||||
export const SharedTypebotsButton = () => {
|
||||
const router = useRouter()
|
||||
|
||||
const handleTypebotClick = () => router.push(`/typebots/shared`)
|
||||
|
||||
return (
|
||||
<Button
|
||||
as={WrapItem}
|
||||
onClick={handleTypebotClick}
|
||||
display="flex"
|
||||
flexDir="column"
|
||||
variant="outline"
|
||||
color="gray.800"
|
||||
w="225px"
|
||||
h="270px"
|
||||
mr={{ sm: 6 }}
|
||||
mb={6}
|
||||
rounded="lg"
|
||||
whiteSpace="normal"
|
||||
cursor="pointer"
|
||||
>
|
||||
<VStack spacing="4">
|
||||
<Flex
|
||||
boxSize="45px"
|
||||
rounded="full"
|
||||
justifyContent="center"
|
||||
alignItems="center"
|
||||
>
|
||||
<UsersIcon fontSize="50" color="orange.300" />
|
||||
</Flex>
|
||||
<Text>Shared with me</Text>
|
||||
</VStack>
|
||||
</Button>
|
||||
)
|
||||
}
|
@ -22,13 +22,15 @@ import { useDebounce } from 'use-debounce'
|
||||
|
||||
type ChatbotCardProps = {
|
||||
typebot: Pick<Typebot, 'id' | 'publishedTypebotId' | 'name'>
|
||||
onTypebotDeleted: () => void
|
||||
onMouseDown: (e: React.MouseEvent<HTMLButtonElement>) => void
|
||||
isReadOnly?: boolean
|
||||
onTypebotDeleted?: () => void
|
||||
onMouseDown?: (e: React.MouseEvent<HTMLButtonElement>) => void
|
||||
}
|
||||
|
||||
export const TypebotButton = ({
|
||||
typebot,
|
||||
onTypebotDeleted,
|
||||
isReadOnly = false,
|
||||
onMouseDown,
|
||||
}: ChatbotCardProps) => {
|
||||
const router = useRouter()
|
||||
@ -55,13 +57,14 @@ export const TypebotButton = ({
|
||||
}
|
||||
|
||||
const handleDeleteTypebotClick = async () => {
|
||||
if (isReadOnly) return
|
||||
const { error } = await deleteTypebot(typebot.id)
|
||||
if (error)
|
||||
return toast({
|
||||
title: "Couldn't delete typebot",
|
||||
description: error.message,
|
||||
})
|
||||
onTypebotDeleted()
|
||||
if (onTypebotDeleted) onTypebotDeleted()
|
||||
}
|
||||
|
||||
const handleDuplicateClick = async (e: React.MouseEvent) => {
|
||||
@ -98,6 +101,8 @@ export const TypebotButton = ({
|
||||
onMouseDown={onMouseDown}
|
||||
cursor="pointer"
|
||||
>
|
||||
{!isReadOnly && (
|
||||
<>
|
||||
<IconButton
|
||||
icon={<GripIcon />}
|
||||
pos="absolute"
|
||||
@ -120,6 +125,8 @@ export const TypebotButton = ({
|
||||
Delete
|
||||
</MenuItem>
|
||||
</MoreButton>
|
||||
</>
|
||||
)}
|
||||
<VStack spacing="4">
|
||||
<Flex
|
||||
boxSize="45px"
|
||||
@ -137,6 +144,7 @@ export const TypebotButton = ({
|
||||
</Flex>
|
||||
<Text>{typebot.name}</Text>
|
||||
</VStack>
|
||||
{!isReadOnly && (
|
||||
<ConfirmModal
|
||||
message={
|
||||
<Text>
|
||||
@ -151,6 +159,7 @@ export const TypebotButton = ({
|
||||
isOpen={isDeleteOpen}
|
||||
onClose={onDeleteClose}
|
||||
/>
|
||||
)}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
@ -17,7 +17,7 @@ import {
|
||||
Tooltip,
|
||||
} from '@chakra-ui/react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { createCustomDomain } from 'services/customDomains'
|
||||
import { createCustomDomain } from 'services/user'
|
||||
|
||||
const hostnameRegex =
|
||||
/^(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9-]*[a-zA-Z0-9])\.)*([A-Za-z0-9]|[A-Za-z0-9][A-Za-z0-9-]*[A-Za-z0-9])$/
|
||||
|
@ -15,7 +15,7 @@ import { ChevronLeftIcon, PlusIcon, TrashIcon } from 'assets/icons'
|
||||
import React, { useState } from 'react'
|
||||
import { useUser } from 'contexts/UserContext'
|
||||
import { CustomDomainModal } from './CustomDomainModal'
|
||||
import { deleteCustomDomain, useCustomDomains } from 'services/customDomains'
|
||||
import { deleteCustomDomain, useCustomDomains } from 'services/user'
|
||||
|
||||
type Props = Omit<MenuButtonProps, 'type'> & {
|
||||
currentCustomDomain?: string
|
||||
|
@ -15,7 +15,7 @@ import React, { useEffect, useMemo, useState } from 'react'
|
||||
import { useUser } from 'contexts/UserContext'
|
||||
import { useRouter } from 'next/router'
|
||||
import { CredentialsType } from 'models'
|
||||
import { deleteCredentials, useCredentials } from 'services/credentials'
|
||||
import { deleteCredentials, useCredentials } from 'services/user'
|
||||
|
||||
type Props = Omit<MenuButtonProps, 'type'> & {
|
||||
type: CredentialsType
|
||||
|
@ -12,7 +12,7 @@ import {
|
||||
import { useUser } from 'contexts/UserContext'
|
||||
import { CredentialsType, SmtpCredentialsData } from 'models'
|
||||
import React, { useState } from 'react'
|
||||
import { createCredentials } from 'services/credentials'
|
||||
import { createCredentials } from 'services/user'
|
||||
import { testSmtpConfig } from 'services/integrations'
|
||||
import { isNotDefined } from 'utils'
|
||||
import { SmtpConfigForm } from './SmtpConfigForm'
|
||||
|
@ -0,0 +1,248 @@
|
||||
import {
|
||||
Stack,
|
||||
HStack,
|
||||
Input,
|
||||
Button,
|
||||
useToast,
|
||||
Menu,
|
||||
MenuButton,
|
||||
MenuItem,
|
||||
MenuList,
|
||||
SkeletonCircle,
|
||||
SkeletonText,
|
||||
} from '@chakra-ui/react'
|
||||
import { ChevronLeftIcon } from 'assets/icons'
|
||||
import { useTypebot } from 'contexts/TypebotContext'
|
||||
import { useUser } from 'contexts/UserContext'
|
||||
import { CollaborationType } from 'db'
|
||||
import React, { FormEvent, useState } from 'react'
|
||||
import {
|
||||
deleteCollaborator,
|
||||
updateCollaborator,
|
||||
useCollaborators,
|
||||
} from 'services/typebots/collaborators'
|
||||
import {
|
||||
useInvitations,
|
||||
updateInvitation,
|
||||
deleteInvitation,
|
||||
sendInvitation,
|
||||
} from 'services/typebots/invitations'
|
||||
import {
|
||||
CollaboratorIdentityContent,
|
||||
CollaboratorItem,
|
||||
} from './CollaboratorButton'
|
||||
|
||||
export const CollaborationList = () => {
|
||||
const { user } = useUser()
|
||||
const { typebot, owner } = useTypebot()
|
||||
const [invitationType, setInvitationType] = useState<CollaborationType>(
|
||||
CollaborationType.READ
|
||||
)
|
||||
const [invitationEmail, setInvitationEmail] = useState('')
|
||||
const [isSendingInvitation, setIsSendingInvitation] = useState(false)
|
||||
|
||||
console.log(user, owner)
|
||||
const isOwner = user?.email === owner?.email
|
||||
|
||||
const toast = useToast({
|
||||
position: 'top-right',
|
||||
status: 'error',
|
||||
})
|
||||
const {
|
||||
collaborators,
|
||||
isLoading: isCollaboratorsLoading,
|
||||
mutate: mutateCollaborators,
|
||||
} = useCollaborators({
|
||||
typebotId: typebot?.id,
|
||||
onError: (e) =>
|
||||
toast({
|
||||
title: "Couldn't fetch collaborators",
|
||||
description: e.message,
|
||||
}),
|
||||
})
|
||||
const {
|
||||
invitations,
|
||||
isLoading: isInvitationsLoading,
|
||||
mutate: mutateInvitations,
|
||||
} = useInvitations({
|
||||
typebotId: typebot?.id,
|
||||
onError: (e) =>
|
||||
toast({ title: "Couldn't fetch collaborators", description: e.message }),
|
||||
})
|
||||
|
||||
const handleChangeInvitationCollabType =
|
||||
(email: string) => async (type: CollaborationType) => {
|
||||
if (!typebot || !isOwner) return
|
||||
const { error } = await updateInvitation(typebot?.id, email, { type })
|
||||
if (error) return toast({ title: error.name, description: error.message })
|
||||
mutateInvitations({
|
||||
invitations: (invitations ?? []).map((i) =>
|
||||
i.email === email ? { ...i, type } : i
|
||||
),
|
||||
})
|
||||
}
|
||||
const handleDeleteInvitation = (email: string) => async () => {
|
||||
if (!typebot || !isOwner) return
|
||||
const { error } = await deleteInvitation(typebot?.id, email)
|
||||
if (error) return toast({ title: error.name, description: error.message })
|
||||
mutateInvitations({
|
||||
invitations: (invitations ?? []).filter((i) => i.email !== email),
|
||||
})
|
||||
}
|
||||
|
||||
const handleChangeCollaborationType =
|
||||
(userId: string) => async (type: CollaborationType) => {
|
||||
if (!typebot || !isOwner) return
|
||||
const { error } = await updateCollaborator(typebot?.id, userId, { type })
|
||||
if (error) return toast({ title: error.name, description: error.message })
|
||||
mutateCollaborators({
|
||||
collaborators: (collaborators ?? []).map((c) =>
|
||||
c.userId === userId ? { ...c, type } : c
|
||||
),
|
||||
})
|
||||
}
|
||||
const handleDeleteCollaboration = (userId: string) => async () => {
|
||||
if (!typebot || !isOwner) return
|
||||
const { error } = await deleteCollaborator(typebot?.id, userId)
|
||||
if (error) return toast({ title: error.name, description: error.message })
|
||||
mutateCollaborators({
|
||||
collaborators: (collaborators ?? []).filter((c) => c.userId !== userId),
|
||||
})
|
||||
}
|
||||
|
||||
const handleInvitationSubmit = async (e: FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!typebot || !isOwner) return
|
||||
setIsSendingInvitation(true)
|
||||
const { error } = await sendInvitation(typebot.id, {
|
||||
email: invitationEmail,
|
||||
type: invitationType,
|
||||
})
|
||||
setIsSendingInvitation(false)
|
||||
mutateInvitations({ invitations: invitations ?? [] })
|
||||
mutateCollaborators({ collaborators: collaborators ?? [] })
|
||||
if (error) return toast({ title: error.name, description: error.message })
|
||||
toast({ status: 'success', title: 'Invitation sent! 📧' })
|
||||
setInvitationEmail('')
|
||||
}
|
||||
|
||||
const hasNobody =
|
||||
(collaborators ?? []).length > 0 ||
|
||||
((invitations ?? []).length > 0 &&
|
||||
!isInvitationsLoading &&
|
||||
!isCollaboratorsLoading)
|
||||
|
||||
return (
|
||||
<Stack spacing={2}>
|
||||
{isOwner && (
|
||||
<HStack
|
||||
as="form"
|
||||
onSubmit={handleInvitationSubmit}
|
||||
pt="4"
|
||||
px="4"
|
||||
pb={hasNobody ? '0' : '4'}
|
||||
>
|
||||
<Input
|
||||
size="sm"
|
||||
placeholder="colleague@company.com"
|
||||
name="inviteEmail"
|
||||
value={invitationEmail}
|
||||
onChange={(e) => setInvitationEmail(e.target.value)}
|
||||
rounded="md"
|
||||
/>
|
||||
|
||||
<CollaborationTypeMenuButton
|
||||
type={invitationType}
|
||||
onChange={setInvitationType}
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
colorScheme="blue"
|
||||
isLoading={isSendingInvitation}
|
||||
flexShrink="0"
|
||||
type="submit"
|
||||
>
|
||||
Invite
|
||||
</Button>
|
||||
</HStack>
|
||||
)}
|
||||
{owner && (collaborators ?? []).length > 0 && (
|
||||
<CollaboratorIdentityContent
|
||||
email={owner.email ?? ''}
|
||||
name={owner.name ?? undefined}
|
||||
image={owner.image ?? undefined}
|
||||
tag="Owner"
|
||||
/>
|
||||
)}
|
||||
{invitations?.map(({ email, type }) => (
|
||||
<CollaboratorItem
|
||||
key={email}
|
||||
email={email}
|
||||
type={type}
|
||||
isOwner={isOwner}
|
||||
onDeleteClick={handleDeleteInvitation(email)}
|
||||
onChangeCollaborationType={handleChangeInvitationCollabType(email)}
|
||||
isGuest
|
||||
/>
|
||||
))}
|
||||
{collaborators?.map(({ user, type, userId }) => (
|
||||
<CollaboratorItem
|
||||
key={userId}
|
||||
email={user.email ?? ''}
|
||||
image={user.image ?? undefined}
|
||||
name={user.name ?? undefined}
|
||||
type={type}
|
||||
isOwner={isOwner}
|
||||
onDeleteClick={handleDeleteCollaboration(user.email ?? '')}
|
||||
onChangeCollaborationType={handleChangeCollaborationType(userId)}
|
||||
/>
|
||||
))}
|
||||
{(isCollaboratorsLoading || isInvitationsLoading) && (
|
||||
<HStack p="4">
|
||||
<SkeletonCircle boxSize="32px" />
|
||||
<SkeletonText width="200px" noOfLines={2} />
|
||||
</HStack>
|
||||
)}
|
||||
</Stack>
|
||||
)
|
||||
}
|
||||
|
||||
const CollaborationTypeMenuButton = ({
|
||||
type,
|
||||
onChange,
|
||||
}: {
|
||||
type: CollaborationType
|
||||
onChange: (type: CollaborationType) => void
|
||||
}) => {
|
||||
return (
|
||||
<Menu placement="bottom-end">
|
||||
<MenuButton
|
||||
flexShrink={0}
|
||||
size="sm"
|
||||
as={Button}
|
||||
rightIcon={<ChevronLeftIcon transform={'rotate(-90deg)'} />}
|
||||
>
|
||||
{convertCollaborationTypeEnumToReadable(type)}
|
||||
</MenuButton>
|
||||
<MenuList minW={0}>
|
||||
<MenuItem onClick={() => onChange(CollaborationType.READ)}>
|
||||
{convertCollaborationTypeEnumToReadable(CollaborationType.READ)}
|
||||
</MenuItem>
|
||||
<MenuItem onClick={() => onChange(CollaborationType.WRITE)}>
|
||||
{convertCollaborationTypeEnumToReadable(CollaborationType.WRITE)}
|
||||
</MenuItem>
|
||||
</MenuList>
|
||||
</Menu>
|
||||
)
|
||||
}
|
||||
|
||||
export const convertCollaborationTypeEnumToReadable = (
|
||||
type: CollaborationType
|
||||
) => {
|
||||
switch (type) {
|
||||
case CollaborationType.READ:
|
||||
return 'Can view'
|
||||
case CollaborationType.WRITE:
|
||||
return 'Can edit'
|
||||
}
|
||||
}
|
@ -0,0 +1,30 @@
|
||||
import {
|
||||
Popover,
|
||||
PopoverTrigger,
|
||||
PopoverContent,
|
||||
IconButton,
|
||||
Tooltip,
|
||||
} from '@chakra-ui/react'
|
||||
import { UsersIcon } from 'assets/icons'
|
||||
import React from 'react'
|
||||
import { CollaborationList } from './CollaborationList'
|
||||
|
||||
export const CollaborationMenuButton = () => {
|
||||
return (
|
||||
<Popover isLazy placement="bottom-end">
|
||||
<PopoverTrigger>
|
||||
<span>
|
||||
<Tooltip label="Invite users to collaborate">
|
||||
<IconButton
|
||||
icon={<UsersIcon />}
|
||||
aria-label="Show collaboration menu"
|
||||
/>
|
||||
</Tooltip>
|
||||
</span>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent shadow="lg" width="430px">
|
||||
<CollaborationList />
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
@ -0,0 +1,101 @@
|
||||
import {
|
||||
Avatar,
|
||||
HStack,
|
||||
Menu,
|
||||
MenuButton,
|
||||
MenuItem,
|
||||
MenuList,
|
||||
Stack,
|
||||
Tag,
|
||||
Text,
|
||||
} from '@chakra-ui/react'
|
||||
import { CollaborationType } from 'db'
|
||||
import React from 'react'
|
||||
import { convertCollaborationTypeEnumToReadable } from './CollaborationList'
|
||||
|
||||
type Props = {
|
||||
image?: string
|
||||
name?: string
|
||||
email: string
|
||||
type: CollaborationType
|
||||
isGuest?: boolean
|
||||
isOwner: boolean
|
||||
onDeleteClick: () => void
|
||||
onChangeCollaborationType: (type: CollaborationType) => void
|
||||
}
|
||||
|
||||
export const CollaboratorItem = ({
|
||||
email,
|
||||
name,
|
||||
image,
|
||||
type,
|
||||
isGuest = false,
|
||||
isOwner,
|
||||
onDeleteClick,
|
||||
onChangeCollaborationType,
|
||||
}: Props) => {
|
||||
const handleEditClick = () =>
|
||||
onChangeCollaborationType(CollaborationType.WRITE)
|
||||
const handleViewClick = () =>
|
||||
onChangeCollaborationType(CollaborationType.READ)
|
||||
return (
|
||||
<Menu placement="bottom-end">
|
||||
<MenuButton _hover={{ backgroundColor: 'gray.100' }} borderRadius="md">
|
||||
<CollaboratorIdentityContent
|
||||
email={email}
|
||||
name={name}
|
||||
image={image}
|
||||
isGuest={isGuest}
|
||||
tag={convertCollaborationTypeEnumToReadable(type)}
|
||||
/>
|
||||
</MenuButton>
|
||||
{isOwner && (
|
||||
<MenuList shadow="lg">
|
||||
<MenuItem onClick={handleEditClick}>
|
||||
{convertCollaborationTypeEnumToReadable(CollaborationType.WRITE)}
|
||||
</MenuItem>
|
||||
<MenuItem onClick={handleViewClick}>
|
||||
{convertCollaborationTypeEnumToReadable(CollaborationType.READ)}
|
||||
</MenuItem>
|
||||
<MenuItem color="red.500" onClick={onDeleteClick}>
|
||||
Remove
|
||||
</MenuItem>
|
||||
</MenuList>
|
||||
)}
|
||||
</Menu>
|
||||
)
|
||||
}
|
||||
|
||||
export const CollaboratorIdentityContent = ({
|
||||
name,
|
||||
tag,
|
||||
isGuest = false,
|
||||
image,
|
||||
email,
|
||||
}: {
|
||||
name?: string
|
||||
tag?: string
|
||||
image?: string
|
||||
isGuest?: boolean
|
||||
email: string
|
||||
}) => (
|
||||
<HStack justifyContent="space-between" maxW="full" py="2" px="4">
|
||||
<HStack minW={0}>
|
||||
<Avatar name={name} src={image} size="sm" />
|
||||
<Stack spacing={0} minW="0">
|
||||
{name && (
|
||||
<Text textAlign="left" fontSize="15px">
|
||||
{name}
|
||||
</Text>
|
||||
)}
|
||||
<Text color="gray.500" fontSize={name ? '14px' : 'inherit'} isTruncated>
|
||||
{email}
|
||||
</Text>
|
||||
</Stack>
|
||||
</HStack>
|
||||
<HStack flexShrink={0}>
|
||||
{isGuest && <Tag color="gray.400">Pending</Tag>}
|
||||
<Tag>{tag}</Tag>
|
||||
</HStack>
|
||||
</HStack>
|
||||
)
|
@ -0,0 +1 @@
|
||||
export * from './CollaborationMenuButton'
|
@ -14,6 +14,7 @@ import { useTypebot } from 'contexts/TypebotContext/TypebotContext'
|
||||
import { useRouter } from 'next/router'
|
||||
import React from 'react'
|
||||
import { PublishButton } from '../buttons/PublishButton'
|
||||
import { CollaborationMenuButton } from './CollaborationMenuButton'
|
||||
import { EditableTypebotName } from './EditableTypebotName'
|
||||
|
||||
export const headerHeight = 56
|
||||
@ -153,6 +154,7 @@ export const TypebotHeader = () => {
|
||||
</HStack>
|
||||
|
||||
<HStack right="40px" pos="absolute">
|
||||
<CollaborationMenuButton />
|
||||
{router.pathname.includes('/edit') && (
|
||||
<Button onClick={handlePreviewClick}>Preview</Button>
|
||||
)}
|
||||
|
@ -19,7 +19,7 @@ import {
|
||||
checkIfTypebotsAreEqual,
|
||||
parseDefaultPublicId,
|
||||
updateTypebot,
|
||||
} from 'services/typebots'
|
||||
} from 'services/typebots/typebots'
|
||||
import { fetcher, preventUserFromRefreshing } from 'services/utils'
|
||||
import useSWR from 'swr'
|
||||
import { isDefined, isNotDefined } from 'utils'
|
||||
@ -33,6 +33,7 @@ import { useDebounce } from 'use-debounce'
|
||||
import { itemsAction, ItemsActions } from './actions/items'
|
||||
import { generate } from 'short-uuid'
|
||||
import { deepEqual } from 'fast-equals'
|
||||
import { User } from 'db'
|
||||
const autoSaveTimeout = 10000
|
||||
|
||||
type UpdateTypebotPayload = Partial<{
|
||||
@ -48,6 +49,8 @@ const typebotContext = createContext<
|
||||
{
|
||||
typebot?: Typebot
|
||||
publishedTypebot?: PublicTypebot
|
||||
owner?: User
|
||||
isReadOnly?: boolean
|
||||
isPublished: boolean
|
||||
isPublishing: boolean
|
||||
isSavingLoading: boolean
|
||||
@ -84,7 +87,9 @@ export const TypebotContext = ({
|
||||
position: 'top-right',
|
||||
status: 'error',
|
||||
})
|
||||
const { typebot, publishedTypebot, isLoading, mutate } = useFetchedTypebot({
|
||||
|
||||
const { typebot, publishedTypebot, owner, isReadOnly, isLoading, mutate } =
|
||||
useFetchedTypebot({
|
||||
typebotId,
|
||||
onError: (error) =>
|
||||
toast({
|
||||
@ -264,6 +269,8 @@ export const TypebotContext = ({
|
||||
value={{
|
||||
typebot: localTypebot,
|
||||
publishedTypebot,
|
||||
owner,
|
||||
isReadOnly,
|
||||
isSavingLoading,
|
||||
save: saveTypebot,
|
||||
undo,
|
||||
@ -297,13 +304,20 @@ export const useFetchedTypebot = ({
|
||||
onError: (error: Error) => void
|
||||
}) => {
|
||||
const { data, error, mutate } = useSWR<
|
||||
{ typebot: Typebot; publishedTypebot?: PublicTypebot },
|
||||
{
|
||||
typebot: Typebot
|
||||
publishedTypebot?: PublicTypebot
|
||||
owner?: User
|
||||
isReadOnly?: boolean
|
||||
},
|
||||
Error
|
||||
>(`/api/typebots/${typebotId}`, fetcher)
|
||||
if (error) onError(error)
|
||||
return {
|
||||
typebot: data?.typebot,
|
||||
publishedTypebot: data?.publishedTypebot,
|
||||
owner: data?.owner,
|
||||
isReadOnly: data?.isReadOnly,
|
||||
isLoading: !error && !data,
|
||||
mutate,
|
||||
}
|
||||
|
@ -5,7 +5,7 @@ import {
|
||||
DraggableStepType,
|
||||
StepIndices,
|
||||
} from 'models'
|
||||
import { parseNewStep } from 'services/typebots'
|
||||
import { parseNewStep } from 'services/typebots/typebots'
|
||||
import { removeEmptyBlocks } from './blocks'
|
||||
import { WritableDraft } from 'immer/dist/types/types-external'
|
||||
import { SetTypebot } from '../TypebotContext'
|
||||
|
@ -7,16 +7,21 @@ import {
|
||||
useEffect,
|
||||
useState,
|
||||
} from 'react'
|
||||
import { TypebotInDashboard } from 'services/typebots'
|
||||
import { TypebotInDashboard } from 'services/typebots/typebots'
|
||||
|
||||
const typebotDndContext = createContext<{
|
||||
draggedTypebot?: TypebotInDashboard
|
||||
setDraggedTypebot: Dispatch<SetStateAction<TypebotInDashboard | undefined>>
|
||||
mouseOverFolderId?: string | null
|
||||
setMouseOverFolderId: Dispatch<SetStateAction<string | undefined | null>>
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
//@ts-ignore
|
||||
}>({})
|
||||
}>({
|
||||
setDraggedTypebot: () => {
|
||||
console.log('Not implemented')
|
||||
},
|
||||
setMouseOverFolderId: () => {
|
||||
console.log('Not implemented')
|
||||
},
|
||||
})
|
||||
|
||||
export const TypebotDndContext = ({ children }: { children: ReactNode }) => {
|
||||
const [draggedTypebot, setDraggedTypebot] = useState<TypebotInDashboard>()
|
||||
|
@ -9,7 +9,7 @@ import {
|
||||
useState,
|
||||
} from 'react'
|
||||
import { isDefined, isNotDefined } from 'utils'
|
||||
import { updateUser as updateUserInDb } from 'services/user'
|
||||
import { updateUser as updateUserInDb } from 'services/user/user'
|
||||
import { useToast } from '@chakra-ui/react'
|
||||
import { deepEqual } from 'fast-equals'
|
||||
import { User } from 'db'
|
||||
@ -56,7 +56,15 @@ export const UserContext = ({ children }: { children: ReactNode }) => {
|
||||
if (!router.isReady) return
|
||||
if (status === 'loading') return
|
||||
if (status === 'unauthenticated' && !isSigningIn())
|
||||
router.replace('/signin')
|
||||
router.replace({
|
||||
pathname: '/signin',
|
||||
query:
|
||||
router.pathname !== '/typebots'
|
||||
? {
|
||||
redirectPath: router.asPath,
|
||||
}
|
||||
: undefined,
|
||||
})
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [status, router])
|
||||
|
||||
|
@ -13,7 +13,7 @@ import { useUser } from 'contexts/UserContext'
|
||||
import { Typebot } from 'models'
|
||||
import { useRouter } from 'next/router'
|
||||
import React, { useState } from 'react'
|
||||
import { createTypebot, importTypebot } from 'services/typebots'
|
||||
import { createTypebot, importTypebot } from 'services/typebots/typebots'
|
||||
import { generate } from 'short-uuid'
|
||||
|
||||
export type TemplateProps = { name: string; emoji: string; fileName: string }
|
||||
|
@ -10,14 +10,14 @@ import { useTypebot } from 'contexts/TypebotContext'
|
||||
import { Graph } from 'components/shared/Graph'
|
||||
|
||||
export const Board = () => {
|
||||
const { typebot } = useTypebot()
|
||||
const { typebot, isReadOnly } = useTypebot()
|
||||
const { rightPanel } = useEditor()
|
||||
|
||||
return (
|
||||
<Flex flex="1" pos="relative" bgColor="gray.50" h="full">
|
||||
<GraphDndContext>
|
||||
<StepsSideBar />
|
||||
<GraphProvider blocks={typebot?.blocks ?? []}>
|
||||
<GraphProvider blocks={typebot?.blocks ?? []} isReadOnly={isReadOnly}>
|
||||
{typebot && <Graph flex="1" typebot={typebot} />}
|
||||
<BoardMenuButton pos="absolute" right="40px" top="20px" />
|
||||
{rightPanel === RightPanel.PREVIEW && <PreviewDrawer />}
|
||||
|
@ -5,7 +5,7 @@ import { useUser } from 'contexts/UserContext'
|
||||
import { useRouter } from 'next/router'
|
||||
import React, { useMemo } from 'react'
|
||||
import { useStats } from 'services/analytics'
|
||||
import { isFreePlan } from 'services/user'
|
||||
import { isFreePlan } from 'services/user/user'
|
||||
import { AnalyticsContent } from './AnalyticsContent'
|
||||
import { SubmissionsContent } from './SubmissionContent'
|
||||
|
||||
|
@ -8,7 +8,7 @@ import {
|
||||
deleteResults,
|
||||
getAllResults,
|
||||
useResults,
|
||||
} from 'services/results'
|
||||
} from 'services/typebots'
|
||||
import { unparse } from 'papaparse'
|
||||
import { UnlockProPlanInfo } from 'components/shared/Info'
|
||||
|
||||
|
@ -7,8 +7,8 @@
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint",
|
||||
"test": "dotenv -e ./playwright/.env -e .env.local -- yarn playwright test",
|
||||
"test:open": "dotenv -e ./playwright/.env -e .env.local -v PWDEBUG=1 -- yarn playwright test"
|
||||
"test": "yarn playwright test",
|
||||
"test:open": "PWDEBUG=1 yarn playwright test"
|
||||
},
|
||||
"msw": {
|
||||
"workerDirectory": "public"
|
||||
@ -40,6 +40,7 @@
|
||||
"aws-sdk": "^2.1073.0",
|
||||
"bot-engine": "*",
|
||||
"browser-image-compression": "^1.0.17",
|
||||
"cuid": "^2.1.8",
|
||||
"db": "*",
|
||||
"deep-object-diff": "^1.1.7",
|
||||
"fast-equals": "^3.0.0",
|
||||
|
@ -18,7 +18,7 @@ import { actions } from 'libs/kbar'
|
||||
import { enableMocks } from 'mocks'
|
||||
import { SupportBubble } from 'components/shared/SupportBubble'
|
||||
|
||||
if (process.env.NEXT_PUBLIC_AUTH_MOCKING === 'enabled') enableMocks()
|
||||
if (process.env.NEXT_PUBLIC_E2E_TEST === 'enabled') enableMocks()
|
||||
|
||||
const App = ({ Component, pageProps }: AppProps) => {
|
||||
useRouterProgressBar()
|
||||
|
@ -1,5 +1,4 @@
|
||||
import NextAuth from 'next-auth'
|
||||
import { PrismaAdapter } from '@next-auth/prisma-adapter'
|
||||
import EmailProvider from 'next-auth/providers/email'
|
||||
import GitHubProvider from 'next-auth/providers/github'
|
||||
import GoogleProvider from 'next-auth/providers/google'
|
||||
@ -7,10 +6,8 @@ import FacebookProvider from 'next-auth/providers/facebook'
|
||||
import prisma from 'libs/prisma'
|
||||
import { Provider } from 'next-auth/providers'
|
||||
import { NextApiRequest, NextApiResponse } from 'next'
|
||||
import { isNotDefined } from 'utils'
|
||||
import { User } from 'db'
|
||||
import { randomUUID } from 'crypto'
|
||||
import { withSentry } from '@sentry/nextjs'
|
||||
import { CustomAdapter } from './adapter'
|
||||
|
||||
const providers: Provider[] = [
|
||||
EmailProvider({
|
||||
@ -52,30 +49,19 @@ if (process.env.FACEBOOK_CLIENT_ID && process.env.FACEBOOK_CLIENT_SECRET)
|
||||
|
||||
const handler = (req: NextApiRequest, res: NextApiResponse) => {
|
||||
NextAuth(req, res, {
|
||||
adapter: PrismaAdapter(prisma),
|
||||
adapter: CustomAdapter(prisma),
|
||||
secret: process.env.ENCRYPTION_SECRET,
|
||||
providers,
|
||||
session: {
|
||||
strategy: 'database',
|
||||
},
|
||||
callbacks: {
|
||||
session: async ({ session, user }) => {
|
||||
const userFromDb = user as User
|
||||
if (isNotDefined(userFromDb.apiToken))
|
||||
userFromDb.apiToken = await generateApiToken(userFromDb.id)
|
||||
return { ...session, user: userFromDb }
|
||||
},
|
||||
session: async ({ session, user }) => ({
|
||||
...session,
|
||||
user,
|
||||
}),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const generateApiToken = async (userId: string) => {
|
||||
const apiToken = randomUUID()
|
||||
await prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: { apiToken },
|
||||
})
|
||||
return apiToken
|
||||
}
|
||||
|
||||
export default withSentry(handler)
|
||||
|
79
apps/builder/pages/api/auth/adapter.ts
Normal file
79
apps/builder/pages/api/auth/adapter.ts
Normal file
@ -0,0 +1,79 @@
|
||||
// Forked from https://github.com/nextauthjs/adapters/blob/main/packages/prisma/src/index.ts
|
||||
import type { PrismaClient, Prisma, Invitation } from 'db'
|
||||
import { randomUUID } from 'crypto'
|
||||
import type { Adapter, AdapterUser } from 'next-auth/adapters'
|
||||
import cuid from 'cuid'
|
||||
|
||||
export function CustomAdapter(p: PrismaClient): Adapter {
|
||||
return {
|
||||
createUser: async (data: Omit<AdapterUser, 'id'>) => {
|
||||
const user = { id: cuid(), email: data.email as string }
|
||||
const invitations = await p.invitation.findMany({
|
||||
where: { email: user.email },
|
||||
})
|
||||
const createdUser = await p.user.create({
|
||||
data: { ...data, id: user.id, apiToken: randomUUID() },
|
||||
})
|
||||
if (invitations.length > 0)
|
||||
await convertInvitationsToCollaborations(p, user, invitations)
|
||||
return createdUser
|
||||
},
|
||||
getUser: (id) => p.user.findUnique({ where: { id } }),
|
||||
getUserByEmail: (email) => p.user.findUnique({ where: { email } }),
|
||||
async getUserByAccount(provider_providerAccountId) {
|
||||
const account = await p.account.findUnique({
|
||||
where: { provider_providerAccountId },
|
||||
select: { user: true },
|
||||
})
|
||||
return account?.user ?? null
|
||||
},
|
||||
updateUser: (data) => p.user.update({ where: { id: data.id }, data }),
|
||||
deleteUser: (id) => p.user.delete({ where: { id } }),
|
||||
linkAccount: (data) => p.account.create({ data }) as any,
|
||||
unlinkAccount: (provider_providerAccountId) =>
|
||||
p.account.delete({ where: { provider_providerAccountId } }) as any,
|
||||
async getSessionAndUser(sessionToken) {
|
||||
const userAndSession = await p.session.findUnique({
|
||||
where: { sessionToken },
|
||||
include: { user: true },
|
||||
})
|
||||
if (!userAndSession) return null
|
||||
const { user, ...session } = userAndSession
|
||||
return { user, session }
|
||||
},
|
||||
createSession: (data) => p.session.create({ data }),
|
||||
updateSession: (data) =>
|
||||
p.session.update({ data, where: { sessionToken: data.sessionToken } }),
|
||||
deleteSession: (sessionToken) =>
|
||||
p.session.delete({ where: { sessionToken } }),
|
||||
createVerificationToken: (data) => p.verificationToken.create({ data }),
|
||||
async useVerificationToken(identifier_token) {
|
||||
try {
|
||||
return await p.verificationToken.delete({ where: { identifier_token } })
|
||||
} catch (error) {
|
||||
if ((error as Prisma.PrismaClientKnownRequestError).code === 'P2025')
|
||||
return null
|
||||
throw error
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const convertInvitationsToCollaborations = async (
|
||||
p: PrismaClient,
|
||||
{ id, email }: { id: string; email: string },
|
||||
invitations: Invitation[]
|
||||
) => {
|
||||
await p.collaboratorsOnTypebots.createMany({
|
||||
data: invitations.map((invitation) => ({
|
||||
typebotId: invitation.typebotId,
|
||||
type: invitation.type,
|
||||
userId: id,
|
||||
})),
|
||||
})
|
||||
return p.invitation.deleteMany({
|
||||
where: {
|
||||
email,
|
||||
},
|
||||
})
|
||||
}
|
@ -3,7 +3,7 @@ import { Prisma, User } from 'db'
|
||||
import prisma from 'libs/prisma'
|
||||
import { NextApiRequest, NextApiResponse } from 'next'
|
||||
import { getSession } from 'next-auth/react'
|
||||
import { parseNewTypebot } from 'services/typebots'
|
||||
import { parseNewTypebot } from 'services/typebots/typebots'
|
||||
import { methodNotAllowed } from 'utils'
|
||||
|
||||
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
|
@ -1,5 +1,5 @@
|
||||
import { withSentry } from '@sentry/nextjs'
|
||||
import { User } from 'db'
|
||||
import { CollaborationType, Prisma, User } from 'db'
|
||||
import prisma from 'libs/prisma'
|
||||
import { NextApiRequest, NextApiResponse } from 'next'
|
||||
import { getSession } from 'next-auth/react'
|
||||
@ -17,33 +17,37 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
const user = session.user as User
|
||||
if (req.method === 'GET') {
|
||||
const typebot = await prisma.typebot.findFirst({
|
||||
where: {
|
||||
id: typebotId,
|
||||
ownerId: user.email === adminEmail ? undefined : user.id,
|
||||
},
|
||||
where: parseWhereFilter(typebotId, user, 'read'),
|
||||
include: {
|
||||
publishedTypebot: true,
|
||||
owner: { select: { email: true, name: true, image: true } },
|
||||
collaborators: { select: { userId: true, type: true } },
|
||||
},
|
||||
})
|
||||
if (!typebot) return res.send({ typebot: null })
|
||||
const { publishedTypebot, ...restOfTypebot } = typebot
|
||||
return res.send({ typebot: restOfTypebot, publishedTypebot })
|
||||
const { publishedTypebot, owner, collaborators, ...restOfTypebot } = typebot
|
||||
const isReadOnly =
|
||||
collaborators.find((c) => c.userId === user.id)?.type ===
|
||||
CollaborationType.READ
|
||||
return res.send({
|
||||
typebot: restOfTypebot,
|
||||
publishedTypebot,
|
||||
owner,
|
||||
isReadOnly,
|
||||
})
|
||||
}
|
||||
|
||||
const canEditTypebot = parseWhereFilter(typebotId, user, 'write')
|
||||
if (req.method === 'DELETE') {
|
||||
const typebots = await prisma.typebot.delete({
|
||||
where: {
|
||||
id_ownerId: {
|
||||
id: typebotId,
|
||||
ownerId: user.id,
|
||||
},
|
||||
},
|
||||
const typebots = await prisma.typebot.deleteMany({
|
||||
where: canEditTypebot,
|
||||
})
|
||||
return res.send({ typebots })
|
||||
}
|
||||
if (req.method === 'PUT') {
|
||||
const data = typeof req.body === 'string' ? JSON.parse(req.body) : req.body
|
||||
const typebots = await prisma.typebot.update({
|
||||
where: { id_ownerId: { id: typebotId, ownerId: user.id } },
|
||||
const typebots = await prisma.typebot.updateMany({
|
||||
where: canEditTypebot,
|
||||
data: {
|
||||
...data,
|
||||
theme: data.theme ?? undefined,
|
||||
@ -54,8 +58,8 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
}
|
||||
if (req.method === 'PATCH') {
|
||||
const data = typeof req.body === 'string' ? JSON.parse(req.body) : req.body
|
||||
const typebots = await prisma.typebot.update({
|
||||
where: { id_ownerId: { id: typebotId, ownerId: user.id } },
|
||||
const typebots = await prisma.typebot.updateMany({
|
||||
where: canEditTypebot,
|
||||
data,
|
||||
})
|
||||
return res.send({ typebots })
|
||||
@ -63,4 +67,26 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
return methodNotAllowed(res)
|
||||
}
|
||||
|
||||
const parseWhereFilter = (
|
||||
typebotId: string,
|
||||
user: User,
|
||||
type: 'read' | 'write'
|
||||
): Prisma.TypebotWhereInput => ({
|
||||
OR: [
|
||||
{
|
||||
id: typebotId,
|
||||
ownerId: user.email === adminEmail ? undefined : user.id,
|
||||
},
|
||||
{
|
||||
id: typebotId,
|
||||
collaborators: {
|
||||
every: {
|
||||
userId: user.id,
|
||||
type: type === 'write' ? CollaborationType.WRITE : undefined,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
export default withSentry(handler)
|
||||
|
@ -0,0 +1,34 @@
|
||||
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)
|
||||
const typebotId = req.query.typebotId as string
|
||||
const userId = req.query.userId as string
|
||||
if (req.method === 'PUT') {
|
||||
const data = req.body
|
||||
await prisma.collaboratorsOnTypebots.upsert({
|
||||
where: { userId_typebotId: { typebotId, userId } },
|
||||
create: data,
|
||||
update: data,
|
||||
})
|
||||
return res.send({
|
||||
message: 'success',
|
||||
})
|
||||
}
|
||||
if (req.method === 'DELETE') {
|
||||
await prisma.collaboratorsOnTypebots.delete({
|
||||
where: { userId_typebotId: { typebotId, userId } },
|
||||
})
|
||||
return res.send({
|
||||
message: 'success',
|
||||
})
|
||||
}
|
||||
methodNotAllowed(res)
|
||||
}
|
||||
|
||||
export default withSentry(handler)
|
23
apps/builder/pages/api/typebots/[typebotId]/collaborators.ts
Normal file
23
apps/builder/pages/api/typebots/[typebotId]/collaborators.ts
Normal file
@ -0,0 +1,23 @@
|
||||
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)
|
||||
const typebotId = req.query.typebotId as string
|
||||
if (req.method === 'GET') {
|
||||
const collaborators = await prisma.collaboratorsOnTypebots.findMany({
|
||||
where: { typebotId },
|
||||
include: { user: { select: { name: true, image: true, email: true } } },
|
||||
})
|
||||
return res.send({
|
||||
collaborators,
|
||||
})
|
||||
}
|
||||
methodNotAllowed(res)
|
||||
}
|
||||
|
||||
export default withSentry(handler)
|
@ -0,0 +1,34 @@
|
||||
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)
|
||||
const typebotId = req.query.typebotId as string
|
||||
const userId = req.query.userId as string
|
||||
if (req.method === 'PUT') {
|
||||
const data = req.body
|
||||
await prisma.collaboratorsOnTypebots.upsert({
|
||||
where: { userId_typebotId: { typebotId, userId } },
|
||||
create: data,
|
||||
update: data,
|
||||
})
|
||||
return res.send({
|
||||
message: 'success',
|
||||
})
|
||||
}
|
||||
if (req.method === 'DELETE') {
|
||||
await prisma.collaboratorsOnTypebots.delete({
|
||||
where: { userId_typebotId: { typebotId, userId } },
|
||||
})
|
||||
return res.send({
|
||||
message: 'success',
|
||||
})
|
||||
}
|
||||
methodNotAllowed(res)
|
||||
}
|
||||
|
||||
export default withSentry(handler)
|
58
apps/builder/pages/api/typebots/[typebotId]/invitations.ts
Normal file
58
apps/builder/pages/api/typebots/[typebotId]/invitations.ts
Normal file
@ -0,0 +1,58 @@
|
||||
import { withSentry } from '@sentry/nextjs'
|
||||
import { invitationToCollaborate } from 'assets/emails/invitationToCollaborate'
|
||||
import { CollaborationType } from 'db'
|
||||
import prisma from 'libs/prisma'
|
||||
import { NextApiRequest, NextApiResponse } from 'next'
|
||||
import { sendEmailNotification } from 'services/api/emails'
|
||||
import { getAuthenticatedUser } from 'services/api/utils'
|
||||
import {
|
||||
badRequest,
|
||||
isNotDefined,
|
||||
methodNotAllowed,
|
||||
notAuthenticated,
|
||||
} from 'utils'
|
||||
|
||||
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
const user = await getAuthenticatedUser(req)
|
||||
if (!user) return notAuthenticated(res)
|
||||
const typebotId = req.query.typebotId as string
|
||||
if (req.method === 'GET') {
|
||||
const invitations = await prisma.invitation.findMany({
|
||||
where: { typebotId },
|
||||
})
|
||||
return res.send({
|
||||
invitations,
|
||||
})
|
||||
}
|
||||
if (req.method === 'POST') {
|
||||
const { email, type } =
|
||||
(req.body as
|
||||
| { email: string | undefined; type: CollaborationType | undefined }
|
||||
| undefined) ?? {}
|
||||
if (!email || !type) return badRequest(res)
|
||||
const existingUser = await prisma.user.findUnique({
|
||||
where: { email },
|
||||
select: { id: true },
|
||||
})
|
||||
if (existingUser)
|
||||
await prisma.collaboratorsOnTypebots.create({
|
||||
data: { type, typebotId, userId: existingUser.id },
|
||||
})
|
||||
else await prisma.invitation.create({ data: { email, type, typebotId } })
|
||||
if (isNotDefined(process.env.NEXT_PUBLIC_E2E_TEST))
|
||||
await sendEmailNotification({
|
||||
to: email,
|
||||
subject: "You've been invited to collaborate 🤝",
|
||||
content: invitationToCollaborate(
|
||||
user.email ?? '',
|
||||
`${process.env.NEXTAUTH_URL}/typebots/shared`
|
||||
),
|
||||
})
|
||||
return res.send({
|
||||
message: 'success',
|
||||
})
|
||||
}
|
||||
methodNotAllowed(res)
|
||||
}
|
||||
|
||||
export default withSentry(handler)
|
@ -0,0 +1,34 @@
|
||||
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)
|
||||
const typebotId = req.query.typebotId as string
|
||||
const email = req.query.email as string
|
||||
if (req.method === 'PUT') {
|
||||
const data = req.body
|
||||
await prisma.invitation.upsert({
|
||||
where: { email_typebotId: { email, typebotId } },
|
||||
create: data,
|
||||
update: data,
|
||||
})
|
||||
return res.send({
|
||||
message: 'success',
|
||||
})
|
||||
}
|
||||
if (req.method === 'DELETE') {
|
||||
await prisma.invitation.delete({
|
||||
where: { email_typebotId: { email, typebotId } },
|
||||
})
|
||||
return res.send({
|
||||
message: 'success',
|
||||
})
|
||||
}
|
||||
methodNotAllowed(res)
|
||||
}
|
||||
|
||||
export default withSentry(handler)
|
@ -3,7 +3,7 @@ import { User } from 'db'
|
||||
import prisma from 'libs/prisma'
|
||||
import { NextApiRequest, NextApiResponse } from 'next'
|
||||
import { getSession } from 'next-auth/react'
|
||||
import { isFreePlan } from 'services/user'
|
||||
import { isFreePlan } from 'services/user/user'
|
||||
import { methodNotAllowed } from 'utils'
|
||||
|
||||
const adminEmail = 'contact@baptiste-arnaud.fr'
|
||||
|
31
apps/builder/pages/api/users/[id]/sharedTypebots.ts
Normal file
31
apps/builder/pages/api/users/[id]/sharedTypebots.ts
Normal file
@ -0,0 +1,31 @@
|
||||
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) => {
|
||||
if (req.method === 'GET') {
|
||||
const user = await getAuthenticatedUser(req)
|
||||
if (!user) return notAuthenticated(res)
|
||||
const isCountOnly = req.query.count as string | undefined
|
||||
if (isCountOnly) {
|
||||
const count = await prisma.collaboratorsOnTypebots.count({
|
||||
where: { userId: user.id },
|
||||
})
|
||||
return res.send({ count })
|
||||
}
|
||||
const sharedTypebots = await prisma.collaboratorsOnTypebots.findMany({
|
||||
where: { userId: user.id },
|
||||
include: {
|
||||
typebot: { select: { name: true, publishedTypebotId: true, id: true } },
|
||||
},
|
||||
})
|
||||
return res.send({
|
||||
sharedTypebots: sharedTypebots.map((typebot) => ({ ...typebot.typebot })),
|
||||
})
|
||||
}
|
||||
methodNotAllowed(res)
|
||||
}
|
||||
|
||||
export default withSentry(handler)
|
@ -13,7 +13,7 @@ import { Banner } from 'components/dashboard/annoucements/AnnoucementBanner'
|
||||
|
||||
const DashboardPage = () => {
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const { query, isReady } = useRouter()
|
||||
const { query, isReady, push } = useRouter()
|
||||
const { user } = useUser()
|
||||
const toast = useToast({
|
||||
position: 'top-right',
|
||||
@ -35,17 +35,20 @@ const DashboardPage = () => {
|
||||
if (!isReady) return
|
||||
const couponCode = query.coupon?.toString()
|
||||
const stripeStatus = query.stripe?.toString()
|
||||
const redirectPath = query.redirectPath as string | undefined
|
||||
|
||||
if (stripeStatus === 'success')
|
||||
toast({
|
||||
title: 'Typebot Pro',
|
||||
description: "You've successfully subscribed 🎉",
|
||||
})
|
||||
if (!couponCode) return
|
||||
if (couponCode) {
|
||||
setIsLoading(true)
|
||||
redeemCoupon(couponCode).then(() => {
|
||||
location.href = '/typebots'
|
||||
})
|
||||
}
|
||||
if (redirectPath) push(redirectPath)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isReady])
|
||||
|
||||
|
47
apps/builder/pages/typebots/shared.tsx
Normal file
47
apps/builder/pages/typebots/shared.tsx
Normal file
@ -0,0 +1,47 @@
|
||||
import React from 'react'
|
||||
import { Flex, Heading, Stack } from '@chakra-ui/layout'
|
||||
import { DashboardHeader } from 'components/dashboard/DashboardHeader'
|
||||
import { Seo } from 'components/Seo'
|
||||
import { BackButton } from 'components/dashboard/FolderContent/BackButton'
|
||||
import { useSharedTypebots } from 'services/user/sharedTypebots'
|
||||
import { useUser } from 'contexts/UserContext'
|
||||
import { useToast, Wrap } from '@chakra-ui/react'
|
||||
import { ButtonSkeleton } from 'components/dashboard/FolderContent/FolderButton'
|
||||
import { TypebotButton } from 'components/dashboard/FolderContent/TypebotButton'
|
||||
|
||||
const SharedTypebotsPage = () => {
|
||||
const { user } = useUser()
|
||||
const toast = useToast({
|
||||
position: 'top-right',
|
||||
status: 'error',
|
||||
})
|
||||
const { sharedTypebots, isLoading } = useSharedTypebots({
|
||||
userId: user?.id,
|
||||
onError: (e) =>
|
||||
toast({ title: "Couldn't fetch shared bots", description: e.message }),
|
||||
})
|
||||
return (
|
||||
<Stack minH="100vh">
|
||||
<Seo title="My typebots" />
|
||||
<DashboardHeader />
|
||||
<Flex w="full" flex="1" justify="center">
|
||||
<Stack w="1000px" spacing={6}>
|
||||
<Heading as="h1">Shared with me</Heading>
|
||||
<Stack>
|
||||
<Flex>
|
||||
<BackButton id={null} />
|
||||
</Flex>
|
||||
<Wrap spacing={4}>
|
||||
{isLoading && <ButtonSkeleton />}
|
||||
{sharedTypebots?.map((typebot) => (
|
||||
<TypebotButton key={typebot.id} typebot={typebot} isReadOnly />
|
||||
))}
|
||||
</Wrap>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Flex>
|
||||
</Stack>
|
||||
)
|
||||
}
|
||||
|
||||
export default SharedTypebotsPage
|
@ -1,6 +1,13 @@
|
||||
import { devices, PlaywrightTestConfig } from '@playwright/test'
|
||||
import path from 'path'
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
require('dotenv').config({
|
||||
path: path.join(__dirname, 'playwright/.env'),
|
||||
})
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
require('dotenv').config({ path: path.join(__dirname, '.env.local') })
|
||||
|
||||
const config: PlaywrightTestConfig = {
|
||||
globalSetup: require.resolve(path.join(__dirname, 'playwright/global-setup')),
|
||||
testDir: path.join(__dirname, 'playwright/tests'),
|
||||
|
@ -6,7 +6,11 @@
|
||||
"localStorage": [
|
||||
{
|
||||
"name": "authenticatedUser",
|
||||
"value": "{\"id\":\"freeUser\",\"name\":\"John Smith\",\"email\":\"john@smith.fr\",\"emailVerified\":null,\"image\":\"https://avatars.githubusercontent.com/u/16015833?v=4\",\"plan\":\"FREE\",\"stripeId\":null}"
|
||||
"value": "{\"id\":\"freeUser\",\"name\":\"Free user\",\"email\":\"free-user@email.com\",\"emailVerified\":null,\"image\":\"https://avatars.githubusercontent.com/u/16015833?v=4\",\"plan\":\"FREE\",\"stripeId\":null}"
|
||||
},
|
||||
{
|
||||
"name": "typebot-20-modal",
|
||||
"value": "hide"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
@ -6,7 +6,11 @@
|
||||
"localStorage": [
|
||||
{
|
||||
"name": "authenticatedUser",
|
||||
"value": "{\"id\":\"proUser\",\"name\":\"John Smith\",\"email\":\"john@smith.com\",\"emailVerified\":null,\"image\":\"https://avatars.githubusercontent.com/u/16015833?v=4\",\"plan\":\"PRO\",\"stripeId\":null}"
|
||||
"value": "{\"id\":\"proUser\",\"name\":\"Pro user\",\"email\":\"pro-user@email.com\",\"emailVerified\":null,\"image\":\"https://avatars.githubusercontent.com/u/16015833?v=4\",\"plan\":\"PRO\",\"stripeId\":null}"
|
||||
},
|
||||
{
|
||||
"name": "typebot-20-modal",
|
||||
"value": "hide"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
@ -8,7 +8,7 @@ import {
|
||||
Step,
|
||||
Typebot,
|
||||
} from 'models'
|
||||
import { DashboardFolder, PrismaClient, User } from 'db'
|
||||
import { CollaborationType, DashboardFolder, PrismaClient, User } from 'db'
|
||||
import { readFileSync } from 'fs'
|
||||
import { encrypt } from 'utils'
|
||||
|
||||
@ -39,6 +39,13 @@ export const createUsers = () =>
|
||||
],
|
||||
})
|
||||
|
||||
export const createCollaboration = (
|
||||
userId: string,
|
||||
typebotId: string,
|
||||
type: CollaborationType
|
||||
) =>
|
||||
prisma.collaboratorsOnTypebots.create({ data: { userId, typebotId, type } })
|
||||
|
||||
export const getSignedInUser = (email: string) =>
|
||||
prisma.user.findFirst({ where: { email } })
|
||||
|
||||
|
71
apps/builder/playwright/tests/collaboration.spec.ts
Normal file
71
apps/builder/playwright/tests/collaboration.spec.ts
Normal file
@ -0,0 +1,71 @@
|
||||
import test, { expect } from '@playwright/test'
|
||||
import { InputStepType, defaultTextInputOptions } from 'models'
|
||||
import path from 'path'
|
||||
import { generate } from 'short-uuid'
|
||||
import { createTypebots, parseDefaultBlockWithStep } from '../services/database'
|
||||
|
||||
const typebotId = generate()
|
||||
|
||||
test.beforeAll(async () => {
|
||||
await createTypebots([
|
||||
{
|
||||
id: typebotId,
|
||||
name: 'Shared typebot',
|
||||
ownerId: 'freeUser',
|
||||
...parseDefaultBlockWithStep({
|
||||
type: InputStepType.TEXT,
|
||||
options: defaultTextInputOptions,
|
||||
}),
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
test.describe('Typebot owner', () => {
|
||||
test.use({
|
||||
storageState: path.join(__dirname, '../freeUser.json'),
|
||||
})
|
||||
test('Can invite collaborators', async ({ page }) => {
|
||||
await page.goto(`/typebots/${typebotId}/edit`)
|
||||
await page.click('button[aria-label="Show collaboration menu"]')
|
||||
await expect(page.locator('text=Free user')).toBeHidden()
|
||||
await page.fill(
|
||||
'input[placeholder="colleague@company.com"]',
|
||||
'guest@email.com'
|
||||
)
|
||||
await page.click('text=Can view')
|
||||
await page.click('text=Can edit')
|
||||
await page.click('text=Invite')
|
||||
await expect(page.locator('text=Pending')).toBeVisible()
|
||||
await expect(page.locator('text=Free user')).toBeHidden()
|
||||
await page.fill(
|
||||
'input[placeholder="colleague@company.com"]',
|
||||
'pro-user@email.com'
|
||||
)
|
||||
await page.click('text=Can edit')
|
||||
await page.click('text=Can view')
|
||||
await page.click('text=Invite')
|
||||
await expect(page.locator('text=Free user')).toBeVisible()
|
||||
await expect(page.locator('text=Pro user')).toBeVisible()
|
||||
await page.click('text="guest@email.com"')
|
||||
await page.click('text="Remove"')
|
||||
await expect(page.locator('text="guest@email.com"')).toBeHidden()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Collaborator', () => {
|
||||
test('should display shared typebots', async ({ page }) => {
|
||||
await page.goto('/typebots')
|
||||
await expect(page.locator('text=Shared')).toBeVisible()
|
||||
await page.click('text=Shared')
|
||||
await page.waitForNavigation()
|
||||
expect(page.url()).toMatch('/typebots/shared')
|
||||
await expect(page.locator('text="Shared typebot"')).toBeVisible()
|
||||
await page.click('text=Shared typebot')
|
||||
await page.click('button[aria-label="Show collaboration menu"]')
|
||||
await page.click('text=Pro user')
|
||||
await expect(page.locator('text="Remove"')).toBeHidden()
|
||||
await expect(page.locator('text=Free user')).toBeVisible()
|
||||
await page.click('text=Block #1', { force: true })
|
||||
await expect(page.locator('input[value="Block #1"]')).toBeHidden()
|
||||
})
|
||||
})
|
27
apps/builder/services/api/emails.ts
Normal file
27
apps/builder/services/api/emails.ts
Normal file
@ -0,0 +1,27 @@
|
||||
import { createTransport } from 'nodemailer'
|
||||
|
||||
export const sendEmailNotification = ({
|
||||
to,
|
||||
subject,
|
||||
content,
|
||||
}: {
|
||||
to: string
|
||||
subject: string
|
||||
content: string
|
||||
}) => {
|
||||
const transporter = createTransport({
|
||||
host: process.env.AUTH_EMAIL_SERVER_HOST,
|
||||
port: Number(process.env.AUTH_EMAIL_SERVER_PORT),
|
||||
auth: {
|
||||
user: process.env.AUTH_EMAIL_SERVER_USER,
|
||||
pass: process.env.AUTH_EMAIL_SERVER_PASSWORD,
|
||||
},
|
||||
})
|
||||
|
||||
return transporter.sendMail({
|
||||
from: `"${process.env.AUTH_EMAIL_FROM_NAME}" <${process.env.AUTH_EMAIL_FROM_EMAIL}>`,
|
||||
to,
|
||||
subject,
|
||||
html: content,
|
||||
})
|
||||
}
|
10
apps/builder/services/api/utils.ts
Normal file
10
apps/builder/services/api/utils.ts
Normal file
@ -0,0 +1,10 @@
|
||||
import { User } from 'db'
|
||||
import { NextApiRequest } from 'next'
|
||||
import { getSession } from 'next-auth/react'
|
||||
|
||||
export const getAuthenticatedUser = async (
|
||||
req: NextApiRequest
|
||||
): Promise<User | undefined> => {
|
||||
const session = await getSession({ req })
|
||||
return session?.user as User | undefined
|
||||
}
|
@ -15,7 +15,7 @@ export const useFolders = ({
|
||||
const { data, error, mutate } = useSWR<{ folders: DashboardFolder[] }, Error>(
|
||||
`/api/folders?${params}`,
|
||||
fetcher,
|
||||
{ dedupingInterval: 0 }
|
||||
{ dedupingInterval: process.env.NEXT_PUBLIC_E2E_TEST ? 0 : undefined }
|
||||
)
|
||||
if (error) onError(error)
|
||||
return {
|
||||
|
48
apps/builder/services/typebots/collaborators.ts
Normal file
48
apps/builder/services/typebots/collaborators.ts
Normal file
@ -0,0 +1,48 @@
|
||||
import { CollaboratorsOnTypebots } from 'db'
|
||||
import { fetcher } from 'services/utils'
|
||||
import useSWR from 'swr'
|
||||
import { sendRequest } from 'utils'
|
||||
|
||||
export type Collaborator = CollaboratorsOnTypebots & {
|
||||
user: {
|
||||
name: string | null
|
||||
image: string | null
|
||||
email: string | null
|
||||
}
|
||||
}
|
||||
|
||||
export const useCollaborators = ({
|
||||
typebotId,
|
||||
onError,
|
||||
}: {
|
||||
typebotId?: string
|
||||
onError: (error: Error) => void
|
||||
}) => {
|
||||
const { data, error, mutate } = useSWR<
|
||||
{ collaborators: Collaborator[] },
|
||||
Error
|
||||
>(typebotId ? `/api/typebots/${typebotId}/collaborators` : null, fetcher)
|
||||
if (error) onError(error)
|
||||
return {
|
||||
collaborators: data?.collaborators,
|
||||
isLoading: !error && !data,
|
||||
mutate,
|
||||
}
|
||||
}
|
||||
|
||||
export const updateCollaborator = (
|
||||
typebotId: string,
|
||||
userId: string,
|
||||
updates: Partial<CollaboratorsOnTypebots>
|
||||
) =>
|
||||
sendRequest({
|
||||
method: 'PUT',
|
||||
url: `/api/typebots/${typebotId}/collaborators/${userId}`,
|
||||
body: updates,
|
||||
})
|
||||
|
||||
export const deleteCollaborator = (typebotId: string, userId: string) =>
|
||||
sendRequest({
|
||||
method: 'DELETE',
|
||||
url: `/api/typebots/${typebotId}/collaborators/${userId}`,
|
||||
})
|
2
apps/builder/services/typebots/index.ts
Normal file
2
apps/builder/services/typebots/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export * from './results'
|
||||
export * from './typebots'
|
51
apps/builder/services/typebots/invitations.ts
Normal file
51
apps/builder/services/typebots/invitations.ts
Normal file
@ -0,0 +1,51 @@
|
||||
import { CollaborationType, Invitation } from 'db'
|
||||
import { fetcher } from 'services/utils'
|
||||
import useSWR from 'swr'
|
||||
import { sendRequest } from 'utils'
|
||||
|
||||
export const useInvitations = ({
|
||||
typebotId,
|
||||
onError,
|
||||
}: {
|
||||
typebotId?: string
|
||||
onError: (error: Error) => void
|
||||
}) => {
|
||||
const { data, error, mutate } = useSWR<{ invitations: Invitation[] }, Error>(
|
||||
typebotId ? `/api/typebots/${typebotId}/invitations` : null,
|
||||
fetcher,
|
||||
{ dedupingInterval: process.env.NEXT_PUBLIC_E2E_TEST ? 0 : undefined }
|
||||
)
|
||||
if (error) onError(error)
|
||||
return {
|
||||
invitations: data?.invitations,
|
||||
isLoading: !error && !data,
|
||||
mutate,
|
||||
}
|
||||
}
|
||||
|
||||
export const sendInvitation = (
|
||||
typebotId: string,
|
||||
{ email, type }: { email: string; type: CollaborationType }
|
||||
) =>
|
||||
sendRequest({
|
||||
method: 'POST',
|
||||
url: `/api/typebots/${typebotId}/invitations`,
|
||||
body: { email, type },
|
||||
})
|
||||
|
||||
export const updateInvitation = (
|
||||
typebotId: string,
|
||||
userId: string,
|
||||
updates: Partial<Invitation>
|
||||
) =>
|
||||
sendRequest({
|
||||
method: 'PUT',
|
||||
url: `/api/typebots/${typebotId}/invitations/${userId}`,
|
||||
body: updates,
|
||||
})
|
||||
|
||||
export const deleteInvitation = (typebotId: string, userId: string) =>
|
||||
sendRequest({
|
||||
method: 'DELETE',
|
||||
url: `/api/typebots/${typebotId}/invitations/${userId}`,
|
||||
})
|
@ -1,9 +1,9 @@
|
||||
import { ResultWithAnswers, VariableWithValue } from 'models'
|
||||
import useSWRInfinite from 'swr/infinite'
|
||||
import { fetcher } from './utils'
|
||||
import { stringify } from 'qs'
|
||||
import { Answer } from 'db'
|
||||
import { isDefined, sendRequest } from 'utils'
|
||||
import { fetcher } from 'services/utils'
|
||||
|
||||
const paginationLimit = 50
|
||||
|
@ -39,7 +39,7 @@ import {
|
||||
import shortId, { generate } from 'short-uuid'
|
||||
import { Typebot } from 'models'
|
||||
import useSWR from 'swr'
|
||||
import { fetcher, omit, toKebabCase } from './utils'
|
||||
import { fetcher, omit, toKebabCase } from '../utils'
|
||||
import {
|
||||
isBubbleStepType,
|
||||
stepTypeHasItems,
|
||||
@ -49,7 +49,7 @@ import {
|
||||
import { deepEqual } from 'fast-equals'
|
||||
import { stringify } from 'qs'
|
||||
import { isChoiceInput, isConditionStep, sendRequest } from 'utils'
|
||||
import { parseBlocksToPublicBlocks } from './publicTypebot'
|
||||
import { parseBlocksToPublicBlocks } from '../publicTypebot'
|
||||
|
||||
export type TypebotInDashboard = Pick<
|
||||
Typebot,
|
||||
@ -66,7 +66,9 @@ export const useTypebots = ({
|
||||
const { data, error, mutate } = useSWR<
|
||||
{ typebots: TypebotInDashboard[] },
|
||||
Error
|
||||
>(`/api/typebots?${params}`, fetcher, { dedupingInterval: 0 })
|
||||
>(`/api/typebots?${params}`, fetcher, {
|
||||
dedupingInterval: process.env.NEXT_PUBLIC_E2E_TEST ? 0 : undefined,
|
||||
})
|
||||
if (error) onError(error)
|
||||
return {
|
||||
typebots: data?.typebots,
|
||||
@ -109,7 +111,6 @@ export const duplicateTypebot = async (typebotId: string) => {
|
||||
},
|
||||
'id'
|
||||
)
|
||||
console.log(duplicatedTypebot)
|
||||
return sendRequest<Typebot>({
|
||||
url: `/api/typebots`,
|
||||
method: 'POST',
|
@ -1,7 +1,7 @@
|
||||
import { Credentials } from 'models'
|
||||
import useSWR from 'swr'
|
||||
import { sendRequest } from 'utils'
|
||||
import { fetcher } from './utils'
|
||||
import { fetcher } from '../utils'
|
||||
|
||||
export const useCredentials = ({
|
||||
userId,
|
@ -2,7 +2,7 @@ import { CustomDomain } from 'db'
|
||||
import { Credentials } from 'models'
|
||||
import useSWR from 'swr'
|
||||
import { sendRequest } from 'utils'
|
||||
import { fetcher } from './utils'
|
||||
import { fetcher } from '../utils'
|
||||
|
||||
export const useCustomDomains = ({
|
||||
userId,
|
3
apps/builder/services/user/index.ts
Normal file
3
apps/builder/services/user/index.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export * from './user'
|
||||
export * from './customDomains'
|
||||
export * from './credentials'
|
42
apps/builder/services/user/sharedTypebots.ts
Normal file
42
apps/builder/services/user/sharedTypebots.ts
Normal file
@ -0,0 +1,42 @@
|
||||
import { Typebot } from 'models'
|
||||
import { fetcher } from 'services/utils'
|
||||
import useSWR from 'swr'
|
||||
import { isNotDefined } from 'utils'
|
||||
|
||||
export const useSharedTypebotsCount = ({
|
||||
userId,
|
||||
onError,
|
||||
}: {
|
||||
userId?: string
|
||||
onError: (error: Error) => void
|
||||
}) => {
|
||||
const { data, error, mutate } = useSWR<{ count: number }, Error>(
|
||||
userId ? `/api/users/${userId}/sharedTypebots?count=true` : null,
|
||||
fetcher
|
||||
)
|
||||
if (error) onError(error)
|
||||
return {
|
||||
totalSharedTypebots: data?.count ?? 0,
|
||||
isLoading: !error && isNotDefined(data?.count),
|
||||
mutate,
|
||||
}
|
||||
}
|
||||
|
||||
export const useSharedTypebots = ({
|
||||
userId,
|
||||
onError,
|
||||
}: {
|
||||
userId?: string
|
||||
onError: (error: Error) => void
|
||||
}) => {
|
||||
const { data, error, mutate } = useSWR<
|
||||
{ sharedTypebots: Pick<Typebot, 'name' | 'id' | 'publishedTypebotId'>[] },
|
||||
Error
|
||||
>(userId ? `/api/users/${userId}/sharedTypebots` : null, fetcher)
|
||||
if (error) onError(error)
|
||||
return {
|
||||
sharedTypebots: data?.sharedTypebots,
|
||||
isLoading: !error && isNotDefined(data),
|
||||
mutate,
|
||||
}
|
||||
}
|
@ -10,7 +10,7 @@
|
||||
"docker:up": "docker-compose up -d",
|
||||
"db:nuke": "docker-compose down --volumes --remove-orphans",
|
||||
"dev": "yarn docker:up && turbo run dev --parallel",
|
||||
"dev:mocking": "yarn docker:up && NEXT_PUBLIC_AUTH_MOCKING=enabled turbo run dev --parallel",
|
||||
"dev:mocking": "yarn docker:up && NEXT_PUBLIC_E2E_TEST=enabled turbo run dev --parallel",
|
||||
"build": "yarn docker:up && turbo run build",
|
||||
"test:builder": "cd apps/builder && yarn test",
|
||||
"lint": "turbo run lint"
|
||||
|
@ -0,0 +1,31 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "CollaborationType" AS ENUM ('READ', 'WRITE');
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Invitation" (
|
||||
"email" TEXT NOT NULL,
|
||||
"typebotId" TEXT NOT NULL,
|
||||
"type" "CollaborationType" NOT NULL
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "CollaboratorsOnTypebots" (
|
||||
"userId" TEXT NOT NULL,
|
||||
"typebotId" TEXT NOT NULL,
|
||||
"type" "CollaborationType" NOT NULL
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Invitation_email_typebotId_key" ON "Invitation"("email", "typebotId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "CollaboratorsOnTypebots_userId_typebotId_key" ON "CollaboratorsOnTypebots"("userId", "typebotId");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Invitation" ADD CONSTRAINT "Invitation_typebotId_fkey" FOREIGN KEY ("typebotId") REFERENCES "Typebot"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "CollaboratorsOnTypebots" ADD CONSTRAINT "CollaboratorsOnTypebots_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "CollaboratorsOnTypebots" ADD CONSTRAINT "CollaboratorsOnTypebots_typebotId_fkey" FOREIGN KEY ("typebotId") REFERENCES "Typebot"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
@ -52,6 +52,7 @@ model User {
|
||||
credentials Credentials[]
|
||||
customDomains CustomDomain[]
|
||||
apiToken String?
|
||||
CollaboratorsOnTypebots CollaboratorsOnTypebots[]
|
||||
}
|
||||
|
||||
model CustomDomain {
|
||||
@ -121,10 +122,36 @@ model Typebot {
|
||||
settings Json
|
||||
publicId String? @unique
|
||||
customDomain String? @unique
|
||||
collaborators CollaboratorsOnTypebots[]
|
||||
invitations Invitation[]
|
||||
|
||||
@@unique([id, ownerId])
|
||||
}
|
||||
|
||||
model Invitation {
|
||||
email String
|
||||
typebotId String
|
||||
typebot Typebot @relation(fields: [typebotId], references: [id], onDelete: Cascade)
|
||||
type CollaborationType
|
||||
|
||||
@@unique([email, typebotId])
|
||||
}
|
||||
|
||||
model CollaboratorsOnTypebots {
|
||||
userId String
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
typebotId String
|
||||
typebot Typebot @relation(fields: [typebotId], references: [id], onDelete: Cascade)
|
||||
type CollaborationType
|
||||
|
||||
@@unique([userId, typebotId])
|
||||
}
|
||||
|
||||
enum CollaborationType {
|
||||
READ
|
||||
WRITE
|
||||
}
|
||||
|
||||
model PublicTypebot {
|
||||
id String @id @default(cuid())
|
||||
createdAt DateTime @default(now())
|
||||
|
@ -12,6 +12,18 @@ import { byId, isDefined } from '.'
|
||||
export const methodNotAllowed = (res: NextApiResponse) =>
|
||||
res.status(405).json({ message: 'Method Not Allowed' })
|
||||
|
||||
export const notAuthenticated = (res: NextApiResponse) =>
|
||||
res.status(401).json({ message: 'Not authenticated' })
|
||||
|
||||
export const notFound = (res: NextApiResponse) =>
|
||||
res.status(404).json({ message: 'Not found' })
|
||||
|
||||
export const badRequest = (res: NextApiResponse) =>
|
||||
res.status(400).json({ message: 'Bad Request' })
|
||||
|
||||
export const forbidden = (res: NextApiResponse) =>
|
||||
res.status(403).json({ message: 'Bad Request' })
|
||||
|
||||
export const initMiddleware =
|
||||
(
|
||||
handler: (
|
||||
|
@ -6353,6 +6353,11 @@ csstype@^3.0.2, csstype@^3.0.6, csstype@^3.0.9:
|
||||
resolved "https://registry.yarnpkg.com/csstype/-/csstype-3.0.10.tgz#2ad3a7bed70f35b965707c092e5f30b327c290e5"
|
||||
integrity sha512-2u44ZG2OcNUO9HDp/Jl8C07x6pU/eTR3ncV91SiK3dhG9TWvRVsCoJw14Ckx5DgWkzGA3waZWO3d7pgqpUI/XA==
|
||||
|
||||
cuid@^2.1.8:
|
||||
version "2.1.8"
|
||||
resolved "https://registry.yarnpkg.com/cuid/-/cuid-2.1.8.tgz#cbb88f954171e0d5747606c0139fb65c5101eac0"
|
||||
integrity sha512-xiEMER6E7TlTPnDxrM4eRiC6TRgjNX9xzEZ5U/Se2YJKr7Mq4pJn/2XEHjl3STcSh96GmkHPcBXLES8M29wyyg==
|
||||
|
||||
cypress@*:
|
||||
version "9.5.0"
|
||||
resolved "https://registry.yarnpkg.com/cypress/-/cypress-9.5.0.tgz#704a79f0d3d4e775f433334eb8f5ae065e3bea31"
|
||||
|
Reference in New Issue
Block a user