5078 ability to invite team members (#5750)
## Added features - update team member setting page - add a section to send invitation by email - add a new invitation email - update email font to 'Trebuchet MS' as Google Inter font is not working, we need to use a web safe font https://templates.mailchimp.com/design/typography/ ## Demo https://github.com/twentyhq/twenty/assets/29927851/c731d883-1599-4281-87e3-0671f36994ae ## Invitation Email 
This commit is contained in:
@ -116,9 +116,9 @@ yarn command:prod cron:messaging:message-list-fetch
|
|||||||
|
|
||||||
You will need to provision an [App Password](https://support.google.com/accounts/answer/185833).
|
You will need to provision an [App Password](https://support.google.com/accounts/answer/185833).
|
||||||
- EMAIL_SMTP_HOST=smtp.gmail.com
|
- EMAIL_SMTP_HOST=smtp.gmail.com
|
||||||
- EMAIL_SERVER_PORT=465
|
- EMAIL_SMTP_PORT=465
|
||||||
- EMAIL_SERVER_USER=gmail_email_address
|
- EMAIL_SMTP_USER=gmail_email_address
|
||||||
- EMAIL_SERVER_PASSWORD='gmail_app_password'
|
- EMAIL_SMTP_PASSWORD='gmail_app_password'
|
||||||
|
|
||||||
</TabItem>
|
</TabItem>
|
||||||
|
|
||||||
@ -126,9 +126,9 @@ yarn command:prod cron:messaging:message-list-fetch
|
|||||||
|
|
||||||
Keep in mind that if you have 2FA enabled, you will need to provision an [App Password](https://support.microsoft.com/en-us/account-billing/manage-app-passwords-for-two-step-verification-d6dc8c6d-4bf7-4851-ad95-6d07799387e9).
|
Keep in mind that if you have 2FA enabled, you will need to provision an [App Password](https://support.microsoft.com/en-us/account-billing/manage-app-passwords-for-two-step-verification-d6dc8c6d-4bf7-4851-ad95-6d07799387e9).
|
||||||
- EMAIL_SMTP_HOST=smtp.office365.com
|
- EMAIL_SMTP_HOST=smtp.office365.com
|
||||||
- EMAIL_SERVER_PORT=587
|
- EMAIL_SMTP_PORT=587
|
||||||
- EMAIL_SERVER_USER=office365_email_address
|
- EMAIL_SMTP_USER=office365_email_address
|
||||||
- EMAIL_SERVER_PASSWORD='office365_password'
|
- EMAIL_SMTP_PASSWORD='office365_password'
|
||||||
|
|
||||||
</TabItem>
|
</TabItem>
|
||||||
|
|
||||||
@ -138,8 +138,8 @@ yarn command:prod cron:messaging:message-list-fetch
|
|||||||
- Run the smtp4dev image: `docker run --rm -it -p 8090:80 -p 2525:25 rnwood/smtp4dev`
|
- Run the smtp4dev image: `docker run --rm -it -p 8090:80 -p 2525:25 rnwood/smtp4dev`
|
||||||
- Access the smtp4dev ui here: [http://localhost:8090](http://localhost:8090)
|
- Access the smtp4dev ui here: [http://localhost:8090](http://localhost:8090)
|
||||||
- Set the following env variables:
|
- Set the following env variables:
|
||||||
- EMAIL_SERVER_HOST=localhost
|
- EMAIL_SMTP_HOST=localhost
|
||||||
- EMAIL_SERVER_PORT=2525
|
- EMAIL_SMTP_PORT=2525
|
||||||
|
|
||||||
</TabItem>
|
</TabItem>
|
||||||
|
|
||||||
@ -218,4 +218,3 @@ yarn command:prod cron:messaging:message-list-fetch
|
|||||||
['CAPTCHA_SITE_KEY', '', 'The captcha site key'],
|
['CAPTCHA_SITE_KEY', '', 'The captcha site key'],
|
||||||
['CAPTCHA_SECRET_KEY', '', 'The captcha secret key'],
|
['CAPTCHA_SECRET_KEY', '', 'The captcha secret key'],
|
||||||
]}></OptionTable>
|
]}></OptionTable>
|
||||||
|
|
||||||
|
|||||||
@ -27,17 +27,25 @@ export const emailTheme = {
|
|||||||
colors: {
|
colors: {
|
||||||
highlighted: grayScale.gray60,
|
highlighted: grayScale.gray60,
|
||||||
primary: grayScale.gray50,
|
primary: grayScale.gray50,
|
||||||
tertiary: grayScale.gray40,
|
tertiary: grayScale.gray35,
|
||||||
inverted: grayScale.gray0,
|
inverted: grayScale.gray0,
|
||||||
},
|
},
|
||||||
|
family: 'Trebuchet MS', // Google Inter not working, we need to use a web safe font, see https://templates.mailchimp.com/design/typography/
|
||||||
weight: {
|
weight: {
|
||||||
regular: 400,
|
regular: 400,
|
||||||
bold: 600,
|
bold: 600,
|
||||||
},
|
},
|
||||||
size: {
|
size: {
|
||||||
|
sm: '12px',
|
||||||
md: '13px',
|
md: '13px',
|
||||||
lg: '16px',
|
lg: '16px',
|
||||||
|
xl: '24px',
|
||||||
},
|
},
|
||||||
|
lineHeight: '20px',
|
||||||
|
},
|
||||||
|
border: {
|
||||||
|
radius: { sm: '4px', md: '8px' },
|
||||||
|
color: { highlighted: grayScale.gray20 },
|
||||||
},
|
},
|
||||||
background: {
|
background: {
|
||||||
colors: { highlight: grayScale.gray15 },
|
colors: { highlight: grayScale.gray15 },
|
||||||
|
|||||||
@ -7,12 +7,8 @@ export const BaseHead = () => {
|
|||||||
<Head>
|
<Head>
|
||||||
<title>Twenty email</title>
|
<title>Twenty email</title>
|
||||||
<Font
|
<Font
|
||||||
fontFamily="Inter"
|
fontFamily={emailTheme.font.family}
|
||||||
fallbackFontFamily="sans-serif"
|
fallbackFontFamily="sans-serif"
|
||||||
webFont={{
|
|
||||||
url: 'https://fonts.googleapis.com/css2?family=Inter:wght@100;200;300;400;500;600;700;800;900&display=swap',
|
|
||||||
format: 'woff2',
|
|
||||||
}}
|
|
||||||
fontStyle="normal"
|
fontStyle="normal"
|
||||||
fontWeight={emailTheme.font.weight.regular}
|
fontWeight={emailTheme.font.weight.regular}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -6,7 +6,7 @@ import { emailTheme } from 'src/common-style';
|
|||||||
const callToActionStyle = {
|
const callToActionStyle = {
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
padding: '8px 32px',
|
padding: '8px 32px',
|
||||||
borderRadius: '8px',
|
borderRadius: emailTheme.border.radius.md,
|
||||||
border: `1px solid ${emailTheme.background.transparent.light}`,
|
border: `1px solid ${emailTheme.background.transparent.light}`,
|
||||||
background: emailTheme.background.radialGradient,
|
background: emailTheme.background.radialGradient,
|
||||||
boxShadow: `0px 2px 4px 0px ${emailTheme.background.transparent.light}, 0px 0px 4px 0px ${emailTheme.background.transparent.medium}`,
|
boxShadow: `0px 2px 4px 0px ${emailTheme.background.transparent.light}, 0px 0px 4px 0px ${emailTheme.background.transparent.medium}`,
|
||||||
|
|||||||
@ -0,0 +1,33 @@
|
|||||||
|
import React, { PropsWithChildren } from 'react';
|
||||||
|
import { Container } from '@react-email/components';
|
||||||
|
|
||||||
|
import { emailTheme } from 'src/common-style';
|
||||||
|
|
||||||
|
type HighlightedContainerProps = PropsWithChildren;
|
||||||
|
|
||||||
|
const highlightedContainerStyle = {
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
background: emailTheme.background.colors.highlight,
|
||||||
|
border: `1px solid ${emailTheme.border.color.highlighted}`,
|
||||||
|
borderRadius: emailTheme.border.radius.md,
|
||||||
|
padding: '24px 48px',
|
||||||
|
gap: '24px',
|
||||||
|
};
|
||||||
|
|
||||||
|
const divStyle = {
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
alignItems: 'center',
|
||||||
|
} as React.CSSProperties;
|
||||||
|
|
||||||
|
export const HighlightedContainer = ({
|
||||||
|
children,
|
||||||
|
}: HighlightedContainerProps) => {
|
||||||
|
return (
|
||||||
|
<Container style={highlightedContainerStyle}>
|
||||||
|
<div style={divStyle}>{children}</div>
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -1,34 +1,30 @@
|
|||||||
import { ReactNode } from 'react';
|
import React, { ReactNode } from 'react';
|
||||||
import { Column } from '@react-email/components';
|
|
||||||
import { Row } from '@react-email/row';
|
|
||||||
import { Text } from '@react-email/text';
|
import { Text } from '@react-email/text';
|
||||||
|
|
||||||
import { emailTheme } from 'src/common-style';
|
import { emailTheme } from 'src/common-style';
|
||||||
|
|
||||||
const rowStyle = {
|
|
||||||
display: 'flex',
|
|
||||||
};
|
|
||||||
|
|
||||||
const highlightedStyle = {
|
const highlightedStyle = {
|
||||||
borderRadius: '4px',
|
borderRadius: emailTheme.border.radius.sm,
|
||||||
background: emailTheme.background.colors.highlight,
|
background: emailTheme.background.colors.highlight,
|
||||||
padding: '4px 8px',
|
padding: '4px 8px',
|
||||||
margin: 0,
|
|
||||||
fontSize: emailTheme.font.size.lg,
|
fontSize: emailTheme.font.size.lg,
|
||||||
fontWeight: emailTheme.font.weight.bold,
|
fontWeight: emailTheme.font.weight.bold,
|
||||||
color: emailTheme.font.colors.highlighted,
|
color: emailTheme.font.colors.highlighted,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const divStyle = {
|
||||||
|
display: 'flex',
|
||||||
|
};
|
||||||
|
|
||||||
type HighlightedTextProps = {
|
type HighlightedTextProps = {
|
||||||
value: ReactNode;
|
value: ReactNode;
|
||||||
|
centered?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const HighlightedText = ({ value }: HighlightedTextProps) => {
|
export const HighlightedText = ({ value }: HighlightedTextProps) => {
|
||||||
return (
|
return (
|
||||||
<Row style={rowStyle}>
|
<div style={divStyle}>
|
||||||
<Column>
|
<Text style={highlightedStyle}>{value}</Text>
|
||||||
<Text style={highlightedStyle}>{value}</Text>
|
</div>
|
||||||
</Column>
|
|
||||||
</Row>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -4,9 +4,12 @@ import { Text } from '@react-email/text';
|
|||||||
import { emailTheme } from 'src/common-style';
|
import { emailTheme } from 'src/common-style';
|
||||||
|
|
||||||
const mainTextStyle = {
|
const mainTextStyle = {
|
||||||
|
fontFamily: emailTheme.font.family,
|
||||||
fontSize: emailTheme.font.size.md,
|
fontSize: emailTheme.font.size.md,
|
||||||
fontWeight: emailTheme.font.weight.regular,
|
fontWeight: emailTheme.font.weight.regular,
|
||||||
color: emailTheme.font.colors.primary,
|
color: emailTheme.font.colors.primary,
|
||||||
|
margin: '0 0 12px 0',
|
||||||
|
lineHeight: emailTheme.font.lineHeight,
|
||||||
};
|
};
|
||||||
|
|
||||||
export const MainText = ({ children }: MainTextProps) => {
|
export const MainText = ({ children }: MainTextProps) => {
|
||||||
|
|||||||
16
packages/twenty-emails/src/components/ShadowText.tsx
Normal file
16
packages/twenty-emails/src/components/ShadowText.tsx
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
import { PropsWithChildren as ShadowTextProps } from 'react';
|
||||||
|
import { Text } from '@react-email/text';
|
||||||
|
|
||||||
|
import { emailTheme } from 'src/common-style';
|
||||||
|
|
||||||
|
const shadowTextStyle = {
|
||||||
|
fontSize: emailTheme.font.size.sm,
|
||||||
|
fontWeight: emailTheme.font.weight.regular,
|
||||||
|
color: emailTheme.font.colors.tertiary,
|
||||||
|
margin: '0 0 12px 0',
|
||||||
|
lineHeight: emailTheme.font.lineHeight,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ShadowText = ({ children }: ShadowTextProps) => {
|
||||||
|
return <Text style={shadowTextStyle}>{children}</Text>;
|
||||||
|
};
|
||||||
23
packages/twenty-emails/src/components/SubTitle.tsx
Normal file
23
packages/twenty-emails/src/components/SubTitle.tsx
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
import { ReactNode } from 'react';
|
||||||
|
import { Heading } from '@react-email/components';
|
||||||
|
|
||||||
|
import { emailTheme } from 'src/common-style';
|
||||||
|
|
||||||
|
type SubTitleProps = {
|
||||||
|
value: ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
const subTitleStyle = {
|
||||||
|
fontFamily: emailTheme.font.family,
|
||||||
|
fontSize: emailTheme.font.size.lg,
|
||||||
|
fontWeight: emailTheme.font.weight.bold,
|
||||||
|
color: emailTheme.font.colors.highlighted,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SubTitle = ({ value }: SubTitleProps) => {
|
||||||
|
return (
|
||||||
|
<Heading style={subTitleStyle} as="h3">
|
||||||
|
{value}
|
||||||
|
</Heading>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -1,10 +1,23 @@
|
|||||||
import { ReactNode } from 'react';
|
import { ReactNode } from 'react';
|
||||||
import { Heading } from '@react-email/components';
|
import { Heading } from '@react-email/components';
|
||||||
|
|
||||||
|
import { emailTheme } from 'src/common-style';
|
||||||
|
|
||||||
type TitleProps = {
|
type TitleProps = {
|
||||||
value: ReactNode;
|
value: ReactNode;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const Title = ({ value }: TitleProps) => {
|
const titleStyle = {
|
||||||
return <Heading as="h1">{value}</Heading>;
|
fontFamily: emailTheme.font.family,
|
||||||
|
fontSize: emailTheme.font.size.xl,
|
||||||
|
fontWeight: emailTheme.font.weight.bold,
|
||||||
|
color: emailTheme.font.colors.highlighted,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Title = ({ value }: TitleProps) => {
|
||||||
|
return (
|
||||||
|
<Heading style={titleStyle} as="h1">
|
||||||
|
{value}
|
||||||
|
</Heading>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
47
packages/twenty-emails/src/components/WhatIsTwenty.tsx
Normal file
47
packages/twenty-emails/src/components/WhatIsTwenty.tsx
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
import { Column, Row } from '@react-email/components';
|
||||||
|
|
||||||
|
import { Link } from 'src/components/Link';
|
||||||
|
import { MainText } from 'src/components/MainText';
|
||||||
|
import { ShadowText } from 'src/components/ShadowText';
|
||||||
|
import { SubTitle } from 'src/components/SubTitle';
|
||||||
|
|
||||||
|
export const WhatIsTwenty = () => {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<SubTitle value="What is Twenty?" />
|
||||||
|
<MainText>
|
||||||
|
A software to help businesses manage their customer data and
|
||||||
|
relationships efficiently.
|
||||||
|
</MainText>
|
||||||
|
<Row>
|
||||||
|
<Column>
|
||||||
|
<ShadowText>
|
||||||
|
<Link href="https://twenty.com/" value="Website" />
|
||||||
|
</ShadowText>
|
||||||
|
</Column>
|
||||||
|
<Column>
|
||||||
|
<ShadowText>
|
||||||
|
<Link href="https://github.com/twentyhq/twenty" value="Github" />
|
||||||
|
</ShadowText>
|
||||||
|
</Column>
|
||||||
|
<Column>
|
||||||
|
<ShadowText>
|
||||||
|
<Link href="https://twenty.com/user-guide" value="User guide" />
|
||||||
|
</ShadowText>
|
||||||
|
</Column>
|
||||||
|
<Column>
|
||||||
|
<ShadowText>
|
||||||
|
<Link href="https://docs.twenty.com/" value="Developers" />
|
||||||
|
</ShadowText>
|
||||||
|
</Column>
|
||||||
|
</Row>
|
||||||
|
<ShadowText>
|
||||||
|
Twenty.com Public Benefit Corporation
|
||||||
|
<br />
|
||||||
|
2261 Market Street #5275
|
||||||
|
<br />
|
||||||
|
San Francisco, CA 94114
|
||||||
|
</ShadowText>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
44
packages/twenty-emails/src/emails/send-invite-link.email.tsx
Normal file
44
packages/twenty-emails/src/emails/send-invite-link.email.tsx
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
import { Img } from '@react-email/components';
|
||||||
|
|
||||||
|
import { BaseEmail } from 'src/components/BaseEmail';
|
||||||
|
import { CallToAction } from 'src/components/CallToAction';
|
||||||
|
import { HighlightedContainer } from 'src/components/HighlightedContainer';
|
||||||
|
import { HighlightedText } from 'src/components/HighlightedText';
|
||||||
|
import { Link } from 'src/components/Link';
|
||||||
|
import { MainText } from 'src/components/MainText';
|
||||||
|
import { Title } from 'src/components/Title';
|
||||||
|
import { WhatIsTwenty } from 'src/components/WhatIsTwenty';
|
||||||
|
import { capitalize } from 'src/utils/capitalize';
|
||||||
|
|
||||||
|
type SendInviteLinkEmailProps = {
|
||||||
|
link: string;
|
||||||
|
workspace: { name: string | undefined; logo: string | undefined };
|
||||||
|
sender: {
|
||||||
|
email: string;
|
||||||
|
firstName: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SendInviteLinkEmail = ({
|
||||||
|
link,
|
||||||
|
workspace,
|
||||||
|
sender,
|
||||||
|
}: SendInviteLinkEmailProps) => {
|
||||||
|
return (
|
||||||
|
<BaseEmail width={333}>
|
||||||
|
<Title value="Join your team on Twenty" />
|
||||||
|
<MainText>
|
||||||
|
{capitalize(sender.firstName)} (
|
||||||
|
<Link href={sender.email} value={sender.email} />) has invited you to
|
||||||
|
join a workspace called <b>{workspace.name}</b>
|
||||||
|
<br />
|
||||||
|
</MainText>
|
||||||
|
<HighlightedContainer>
|
||||||
|
{workspace.logo && <Img src={workspace.logo} width={40} height={40} />}
|
||||||
|
{workspace.name && <HighlightedText value={workspace.name} />}
|
||||||
|
<CallToAction href={link} value="Accept invite" />
|
||||||
|
</HighlightedContainer>
|
||||||
|
<WhatIsTwenty />
|
||||||
|
</BaseEmail>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -2,3 +2,4 @@ export * from './emails/clean-inactive-workspaces.email';
|
|||||||
export * from './emails/delete-inactive-workspaces.email';
|
export * from './emails/delete-inactive-workspaces.email';
|
||||||
export * from './emails/password-reset-link.email';
|
export * from './emails/password-reset-link.email';
|
||||||
export * from './emails/password-update-notify.email';
|
export * from './emails/password-update-notify.email';
|
||||||
|
export * from './emails/send-invite-link.email';
|
||||||
|
|||||||
7
packages/twenty-emails/src/utils/capitalize.ts
Normal file
7
packages/twenty-emails/src/utils/capitalize.ts
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
import { isNonEmptyString } from '@sniptt/guards';
|
||||||
|
|
||||||
|
export const capitalize = (stringToCapitalize: string) => {
|
||||||
|
if (!isNonEmptyString(stringToCapitalize)) return '';
|
||||||
|
|
||||||
|
return stringToCapitalize[0].toUpperCase() + stringToCapitalize.slice(1);
|
||||||
|
};
|
||||||
@ -139,6 +139,7 @@ export type ClientConfig = {
|
|||||||
authProviders: AuthProviders;
|
authProviders: AuthProviders;
|
||||||
billing: Billing;
|
billing: Billing;
|
||||||
captcha: Captcha;
|
captcha: Captcha;
|
||||||
|
chromeExtensionId?: Maybe<Scalars['String']['output']>;
|
||||||
debugMode: Scalars['Boolean']['output'];
|
debugMode: Scalars['Boolean']['output'];
|
||||||
sentry: Sentry;
|
sentry: Sentry;
|
||||||
signInPrefilled: Scalars['Boolean']['output'];
|
signInPrefilled: Scalars['Boolean']['output'];
|
||||||
@ -391,6 +392,7 @@ export type Mutation = {
|
|||||||
generateTransientToken: TransientToken;
|
generateTransientToken: TransientToken;
|
||||||
impersonate: Verify;
|
impersonate: Verify;
|
||||||
renewToken: AuthTokens;
|
renewToken: AuthTokens;
|
||||||
|
sendInviteLink: SendInviteLink;
|
||||||
signUp: LoginToken;
|
signUp: LoginToken;
|
||||||
syncRemoteTable: RemoteTable;
|
syncRemoteTable: RemoteTable;
|
||||||
syncRemoteTableSchemaChanges: RemoteTable;
|
syncRemoteTableSchemaChanges: RemoteTable;
|
||||||
@ -518,6 +520,11 @@ export type MutationRenewTokenArgs = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
export type MutationSendInviteLinkArgs = {
|
||||||
|
emails: Array<Scalars['String']['input']>;
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
export type MutationSignUpArgs = {
|
export type MutationSignUpArgs = {
|
||||||
captchaToken?: InputMaybe<Scalars['String']['input']>;
|
captchaToken?: InputMaybe<Scalars['String']['input']>;
|
||||||
email: Scalars['String']['input'];
|
email: Scalars['String']['input'];
|
||||||
@ -849,6 +856,12 @@ export enum RemoteTableStatus {
|
|||||||
Synced = 'SYNCED'
|
Synced = 'SYNCED'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type SendInviteLink = {
|
||||||
|
__typename?: 'SendInviteLink';
|
||||||
|
/** Boolean that confirms query was dispatched */
|
||||||
|
success: Scalars['Boolean']['output'];
|
||||||
|
};
|
||||||
|
|
||||||
export type Sentry = {
|
export type Sentry = {
|
||||||
__typename?: 'Sentry';
|
__typename?: 'Sentry';
|
||||||
dsn?: Maybe<Scalars['String']['output']>;
|
dsn?: Maybe<Scalars['String']['output']>;
|
||||||
|
|||||||
@ -285,6 +285,7 @@ export type Mutation = {
|
|||||||
generateTransientToken: TransientToken;
|
generateTransientToken: TransientToken;
|
||||||
impersonate: Verify;
|
impersonate: Verify;
|
||||||
renewToken: AuthTokens;
|
renewToken: AuthTokens;
|
||||||
|
sendInviteLink: SendInviteLink;
|
||||||
signUp: LoginToken;
|
signUp: LoginToken;
|
||||||
track: Analytics;
|
track: Analytics;
|
||||||
updateBillingSubscription: UpdateBillingEntity;
|
updateBillingSubscription: UpdateBillingEntity;
|
||||||
@ -367,6 +368,11 @@ export type MutationRenewTokenArgs = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
export type MutationSendInviteLinkArgs = {
|
||||||
|
emails: Array<Scalars['String']>;
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
export type MutationSignUpArgs = {
|
export type MutationSignUpArgs = {
|
||||||
captchaToken?: InputMaybe<Scalars['String']>;
|
captchaToken?: InputMaybe<Scalars['String']>;
|
||||||
email: Scalars['String'];
|
email: Scalars['String'];
|
||||||
@ -584,6 +590,7 @@ export type RemoteServer = {
|
|||||||
foreignDataWrapperOptions?: Maybe<Scalars['JSON']>;
|
foreignDataWrapperOptions?: Maybe<Scalars['JSON']>;
|
||||||
foreignDataWrapperType: Scalars['String'];
|
foreignDataWrapperType: Scalars['String'];
|
||||||
id: Scalars['ID'];
|
id: Scalars['ID'];
|
||||||
|
label: Scalars['String'];
|
||||||
schema?: Maybe<Scalars['String']>;
|
schema?: Maybe<Scalars['String']>;
|
||||||
updatedAt: Scalars['DateTime'];
|
updatedAt: Scalars['DateTime'];
|
||||||
userMappingOptions?: Maybe<UserMappingOptionsUser>;
|
userMappingOptions?: Maybe<UserMappingOptionsUser>;
|
||||||
@ -604,6 +611,12 @@ export enum RemoteTableStatus {
|
|||||||
Synced = 'SYNCED'
|
Synced = 'SYNCED'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type SendInviteLink = {
|
||||||
|
__typename?: 'SendInviteLink';
|
||||||
|
/** Boolean that confirms query was dispatched */
|
||||||
|
success: Scalars['Boolean'];
|
||||||
|
};
|
||||||
|
|
||||||
export type Sentry = {
|
export type Sentry = {
|
||||||
__typename?: 'Sentry';
|
__typename?: 'Sentry';
|
||||||
dsn?: Maybe<Scalars['String']>;
|
dsn?: Maybe<Scalars['String']>;
|
||||||
@ -1227,6 +1240,13 @@ export type DeleteCurrentWorkspaceMutationVariables = Exact<{ [key: string]: nev
|
|||||||
|
|
||||||
export type DeleteCurrentWorkspaceMutation = { __typename?: 'Mutation', deleteCurrentWorkspace: { __typename?: 'Workspace', id: any } };
|
export type DeleteCurrentWorkspaceMutation = { __typename?: 'Mutation', deleteCurrentWorkspace: { __typename?: 'Workspace', id: any } };
|
||||||
|
|
||||||
|
export type SendInviteLinkMutationVariables = Exact<{
|
||||||
|
emails: Array<Scalars['String']> | Scalars['String'];
|
||||||
|
}>;
|
||||||
|
|
||||||
|
|
||||||
|
export type SendInviteLinkMutation = { __typename?: 'Mutation', sendInviteLink: { __typename?: 'SendInviteLink', success: boolean } };
|
||||||
|
|
||||||
export type UpdateWorkspaceMutationVariables = Exact<{
|
export type UpdateWorkspaceMutationVariables = Exact<{
|
||||||
input: UpdateWorkspaceInput;
|
input: UpdateWorkspaceInput;
|
||||||
}>;
|
}>;
|
||||||
@ -2579,6 +2599,39 @@ export function useDeleteCurrentWorkspaceMutation(baseOptions?: Apollo.MutationH
|
|||||||
export type DeleteCurrentWorkspaceMutationHookResult = ReturnType<typeof useDeleteCurrentWorkspaceMutation>;
|
export type DeleteCurrentWorkspaceMutationHookResult = ReturnType<typeof useDeleteCurrentWorkspaceMutation>;
|
||||||
export type DeleteCurrentWorkspaceMutationResult = Apollo.MutationResult<DeleteCurrentWorkspaceMutation>;
|
export type DeleteCurrentWorkspaceMutationResult = Apollo.MutationResult<DeleteCurrentWorkspaceMutation>;
|
||||||
export type DeleteCurrentWorkspaceMutationOptions = Apollo.BaseMutationOptions<DeleteCurrentWorkspaceMutation, DeleteCurrentWorkspaceMutationVariables>;
|
export type DeleteCurrentWorkspaceMutationOptions = Apollo.BaseMutationOptions<DeleteCurrentWorkspaceMutation, DeleteCurrentWorkspaceMutationVariables>;
|
||||||
|
export const SendInviteLinkDocument = gql`
|
||||||
|
mutation SendInviteLink($emails: [String!]!) {
|
||||||
|
sendInviteLink(emails: $emails) {
|
||||||
|
success
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
export type SendInviteLinkMutationFn = Apollo.MutationFunction<SendInviteLinkMutation, SendInviteLinkMutationVariables>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* __useSendInviteLinkMutation__
|
||||||
|
*
|
||||||
|
* To run a mutation, you first call `useSendInviteLinkMutation` within a React component and pass it any options that fit your needs.
|
||||||
|
* When your component renders, `useSendInviteLinkMutation` returns a tuple that includes:
|
||||||
|
* - A mutate function that you can call at any time to execute the mutation
|
||||||
|
* - An object with fields that represent the current status of the mutation's execution
|
||||||
|
*
|
||||||
|
* @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2;
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const [sendInviteLinkMutation, { data, loading, error }] = useSendInviteLinkMutation({
|
||||||
|
* variables: {
|
||||||
|
* emails: // value for 'emails'
|
||||||
|
* },
|
||||||
|
* });
|
||||||
|
*/
|
||||||
|
export function useSendInviteLinkMutation(baseOptions?: Apollo.MutationHookOptions<SendInviteLinkMutation, SendInviteLinkMutationVariables>) {
|
||||||
|
const options = {...defaultOptions, ...baseOptions}
|
||||||
|
return Apollo.useMutation<SendInviteLinkMutation, SendInviteLinkMutationVariables>(SendInviteLinkDocument, options);
|
||||||
|
}
|
||||||
|
export type SendInviteLinkMutationHookResult = ReturnType<typeof useSendInviteLinkMutation>;
|
||||||
|
export type SendInviteLinkMutationResult = Apollo.MutationResult<SendInviteLinkMutation>;
|
||||||
|
export type SendInviteLinkMutationOptions = Apollo.BaseMutationOptions<SendInviteLinkMutation, SendInviteLinkMutationVariables>;
|
||||||
export const UpdateWorkspaceDocument = gql`
|
export const UpdateWorkspaceDocument = gql`
|
||||||
mutation UpdateWorkspace($input: UpdateWorkspaceInput!) {
|
mutation UpdateWorkspace($input: UpdateWorkspaceInput!) {
|
||||||
updateWorkspace(data: $input) {
|
updateWorkspace(data: $input) {
|
||||||
|
|||||||
@ -34,12 +34,17 @@ const StyledInputContainer = styled.div`
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const StyledInput = styled.input<Pick<TextInputV2ComponentProps, 'fullWidth'>>`
|
const StyledInput = styled.input<
|
||||||
|
Pick<TextInputV2ComponentProps, 'fullWidth' | 'LeftIcon'>
|
||||||
|
>`
|
||||||
background-color: ${({ theme }) => theme.background.transparent.lighter};
|
background-color: ${({ theme }) => theme.background.transparent.lighter};
|
||||||
border: 1px solid ${({ theme }) => theme.border.color.medium};
|
border: 1px solid ${({ theme }) => theme.border.color.medium};
|
||||||
border-bottom-left-radius: ${({ theme }) => theme.border.radius.sm};
|
border-bottom-left-radius: ${({ theme, LeftIcon }) =>
|
||||||
|
!LeftIcon && theme.border.radius.sm};
|
||||||
border-right: none;
|
border-right: none;
|
||||||
border-top-left-radius: ${({ theme }) => theme.border.radius.sm};
|
border-left: ${({ LeftIcon }) => LeftIcon && 'none'};
|
||||||
|
border-top-left-radius: ${({ theme, LeftIcon }) =>
|
||||||
|
!LeftIcon && theme.border.radius.sm};
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
color: ${({ theme }) => theme.font.color.primary};
|
color: ${({ theme }) => theme.font.color.primary};
|
||||||
display: flex;
|
display: flex;
|
||||||
@ -69,6 +74,18 @@ const StyledErrorHelper = styled.div`
|
|||||||
padding: ${({ theme }) => theme.spacing(1)};
|
padding: ${({ theme }) => theme.spacing(1)};
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
const StyledLeftIconContainer = styled.div`
|
||||||
|
align-items: center;
|
||||||
|
background-color: ${({ theme }) => theme.background.transparent.lighter};
|
||||||
|
border: 1px solid ${({ theme }) => theme.border.color.medium};
|
||||||
|
border-bottom-left-radius: ${({ theme }) => theme.border.radius.sm};
|
||||||
|
border-right: none;
|
||||||
|
border-top-left-radius: ${({ theme }) => theme.border.radius.sm};
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
padding-left: ${({ theme }) => theme.spacing(2)};
|
||||||
|
`;
|
||||||
|
|
||||||
const StyledTrailingIconContainer = styled.div`
|
const StyledTrailingIconContainer = styled.div`
|
||||||
align-items: center;
|
align-items: center;
|
||||||
background-color: ${({ theme }) => theme.background.transparent.lighter};
|
background-color: ${({ theme }) => theme.background.transparent.lighter};
|
||||||
@ -101,6 +118,7 @@ export type TextInputV2ComponentProps = Omit<
|
|||||||
fullWidth?: boolean;
|
fullWidth?: boolean;
|
||||||
error?: string;
|
error?: string;
|
||||||
RightIcon?: IconComponent;
|
RightIcon?: IconComponent;
|
||||||
|
LeftIcon?: IconComponent;
|
||||||
onKeyDown?: (event: React.KeyboardEvent<HTMLInputElement>) => void;
|
onKeyDown?: (event: React.KeyboardEvent<HTMLInputElement>) => void;
|
||||||
onBlur?: FocusEventHandler<HTMLInputElement>;
|
onBlur?: FocusEventHandler<HTMLInputElement>;
|
||||||
};
|
};
|
||||||
@ -123,6 +141,7 @@ const TextInputV2Component = (
|
|||||||
disabled,
|
disabled,
|
||||||
tabIndex,
|
tabIndex,
|
||||||
RightIcon,
|
RightIcon,
|
||||||
|
LeftIcon,
|
||||||
autoComplete,
|
autoComplete,
|
||||||
}: TextInputV2ComponentProps,
|
}: TextInputV2ComponentProps,
|
||||||
// eslint-disable-next-line @nx/workspace-component-props-naming
|
// eslint-disable-next-line @nx/workspace-component-props-naming
|
||||||
@ -143,6 +162,13 @@ const TextInputV2Component = (
|
|||||||
<StyledContainer className={className} fullWidth={fullWidth ?? false}>
|
<StyledContainer className={className} fullWidth={fullWidth ?? false}>
|
||||||
{label && <StyledLabel>{label + (required ? '*' : '')}</StyledLabel>}
|
{label && <StyledLabel>{label + (required ? '*' : '')}</StyledLabel>}
|
||||||
<StyledInputContainer>
|
<StyledInputContainer>
|
||||||
|
{!!LeftIcon && (
|
||||||
|
<StyledLeftIconContainer>
|
||||||
|
<StyledTrailingIcon>
|
||||||
|
<LeftIcon size={theme.icon.size.md} />
|
||||||
|
</StyledTrailingIcon>
|
||||||
|
</StyledLeftIconContainer>
|
||||||
|
)}
|
||||||
<StyledInput
|
<StyledInput
|
||||||
autoComplete={autoComplete || 'off'}
|
autoComplete={autoComplete || 'off'}
|
||||||
ref={combinedRef}
|
ref={combinedRef}
|
||||||
@ -154,7 +180,7 @@ const TextInputV2Component = (
|
|||||||
onChange?.(event.target.value);
|
onChange?.(event.target.value);
|
||||||
}}
|
}}
|
||||||
onKeyDown={onKeyDown}
|
onKeyDown={onKeyDown}
|
||||||
{...{ autoFocus, disabled, placeholder, required, value }}
|
{...{ autoFocus, disabled, placeholder, required, value, LeftIcon }}
|
||||||
/>
|
/>
|
||||||
<StyledTrailingIconContainer>
|
<StyledTrailingIconContainer>
|
||||||
{error && (
|
{error && (
|
||||||
|
|||||||
@ -0,0 +1,143 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
|
import { Controller, useForm } from 'react-hook-form';
|
||||||
|
import { useTheme } from '@emotion/react';
|
||||||
|
import styled from '@emotion/styled';
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import { Key } from 'ts-key-enum';
|
||||||
|
import { IconCopy, IconMail, IconSend } from 'twenty-ui';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { SnackBarVariant } from '@/ui/feedback/snack-bar-manager/components/SnackBar';
|
||||||
|
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
|
||||||
|
import { Button } from '@/ui/input/button/components/Button';
|
||||||
|
import { TextInput } from '@/ui/input/components/TextInput';
|
||||||
|
import { extractEmailsList } from '@/workspace/utils/extractEmailList';
|
||||||
|
import { useSendInviteLinkMutation } from '~/generated/graphql';
|
||||||
|
import { isDefined } from '~/utils/isDefined';
|
||||||
|
|
||||||
|
const StyledContainer = styled.div`
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const StyledLinkContainer = styled.div`
|
||||||
|
flex: 1;
|
||||||
|
margin-right: ${({ theme }) => theme.spacing(2)};
|
||||||
|
`;
|
||||||
|
|
||||||
|
const emailValidationSchema = (email: string) =>
|
||||||
|
z.string().email(`Invalid email '${email}'`);
|
||||||
|
|
||||||
|
const validationSchema = () =>
|
||||||
|
z
|
||||||
|
.object({
|
||||||
|
emails: z.string().superRefine((value, ctx) => {
|
||||||
|
if (!value.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const emails = extractEmailsList(value);
|
||||||
|
if (emails.length === 0) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.invalid_string,
|
||||||
|
message: 'Emails should not be empty',
|
||||||
|
validation: 'email',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const invalidEmails: string[] = [];
|
||||||
|
for (const email of emails) {
|
||||||
|
const result = emailValidationSchema(email).safeParse(email);
|
||||||
|
if (!result.success) {
|
||||||
|
invalidEmails.push(email);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (invalidEmails.length > 0) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.invalid_string,
|
||||||
|
message:
|
||||||
|
invalidEmails.length > 1
|
||||||
|
? 'Emails "' + invalidEmails.join('", "') + '" are invalid'
|
||||||
|
: 'Email "' + invalidEmails.join('", "') + '" is invalid',
|
||||||
|
validation: 'email',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
.required();
|
||||||
|
|
||||||
|
type FormInput = {
|
||||||
|
emails: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const WorkspaceInviteTeam = () => {
|
||||||
|
const theme = useTheme();
|
||||||
|
const { enqueueSnackBar } = useSnackBar();
|
||||||
|
const [sendInviteLink] = useSendInviteLinkMutation();
|
||||||
|
|
||||||
|
const { reset, handleSubmit, control, formState } = useForm<FormInput>({
|
||||||
|
mode: 'onSubmit',
|
||||||
|
resolver: zodResolver(validationSchema()),
|
||||||
|
defaultValues: {
|
||||||
|
emails: '',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const submit = handleSubmit(async (data) => {
|
||||||
|
const emailsList = extractEmailsList(data.emails);
|
||||||
|
const result = await sendInviteLink({ variables: { emails: emailsList } });
|
||||||
|
if (isDefined(result.errors)) {
|
||||||
|
throw result.errors;
|
||||||
|
}
|
||||||
|
enqueueSnackBar('Invite link sent to email addresses', {
|
||||||
|
variant: SnackBarVariant.Success,
|
||||||
|
icon: <IconCopy size={theme.icon.size.md} />,
|
||||||
|
duration: 2000,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||||
|
if (e.key === Key.Enter) {
|
||||||
|
submit();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const { isSubmitSuccessful } = formState;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isSubmitSuccessful) {
|
||||||
|
reset();
|
||||||
|
}
|
||||||
|
}, [isSubmitSuccessful, reset]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={submit}>
|
||||||
|
<StyledContainer>
|
||||||
|
<StyledLinkContainer>
|
||||||
|
<Controller
|
||||||
|
name="emails"
|
||||||
|
control={control}
|
||||||
|
render={({ field: { value, onChange }, fieldState: { error } }) => {
|
||||||
|
return (
|
||||||
|
<TextInput
|
||||||
|
placeholder="tim@apple.com, jony.ive@apple.dev"
|
||||||
|
LeftIcon={IconMail}
|
||||||
|
value={value}
|
||||||
|
onChange={onChange}
|
||||||
|
error={error?.message}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
fullWidth
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</StyledLinkContainer>
|
||||||
|
<Button
|
||||||
|
Icon={IconSend}
|
||||||
|
variant="primary"
|
||||||
|
accent="blue"
|
||||||
|
title="Invite"
|
||||||
|
type="submit"
|
||||||
|
/>
|
||||||
|
</StyledContainer>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -0,0 +1,9 @@
|
|||||||
|
import { gql } from '@apollo/client';
|
||||||
|
|
||||||
|
export const SEND_INVITE_LINK = gql`
|
||||||
|
mutation SendInviteLink($emails: [String!]!) {
|
||||||
|
sendInviteLink(emails: $emails) {
|
||||||
|
success
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
@ -0,0 +1,28 @@
|
|||||||
|
import { extractEmailsList } from '@/workspace/utils/extractEmailList';
|
||||||
|
|
||||||
|
describe('extractEmailList', () => {
|
||||||
|
it('should extract email list', () => {
|
||||||
|
expect(extractEmailsList('toto@toto.com')).toEqual(['toto@toto.com']);
|
||||||
|
});
|
||||||
|
it('should extract email list with multiple emails', () => {
|
||||||
|
expect(extractEmailsList('toto@toto.com,toto2@toto.com')).toEqual([
|
||||||
|
'toto@toto.com',
|
||||||
|
'toto2@toto.com',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
it('should extract email list with multiple emails and wrong emails', () => {
|
||||||
|
expect(extractEmailsList('toto@toto.com,toto2@toto.com,toto')).toEqual([
|
||||||
|
'toto@toto.com',
|
||||||
|
'toto2@toto.com',
|
||||||
|
'toto',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
it('should remove duplicates', () => {
|
||||||
|
expect(extractEmailsList('toto@toto.com,toto@toto.com')).toEqual([
|
||||||
|
'toto@toto.com',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
it('should remove empty emails', () => {
|
||||||
|
expect(extractEmailsList('toto@toto.com,')).toEqual(['toto@toto.com']);
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -0,0 +1,10 @@
|
|||||||
|
export const extractEmailsList = (emails: string) => {
|
||||||
|
return Array.from(
|
||||||
|
new Set(
|
||||||
|
emails
|
||||||
|
.split(',')
|
||||||
|
.map((email) => email.trim())
|
||||||
|
.filter((email) => email.length > 0),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -14,6 +14,7 @@ import { ConfirmationModal } from '@/ui/layout/modal/components/ConfirmationModa
|
|||||||
import { SubMenuTopBarContainer } from '@/ui/layout/page/SubMenuTopBarContainer';
|
import { SubMenuTopBarContainer } from '@/ui/layout/page/SubMenuTopBarContainer';
|
||||||
import { Section } from '@/ui/layout/section/components/Section';
|
import { Section } from '@/ui/layout/section/components/Section';
|
||||||
import { WorkspaceInviteLink } from '@/workspace/components/WorkspaceInviteLink';
|
import { WorkspaceInviteLink } from '@/workspace/components/WorkspaceInviteLink';
|
||||||
|
import { WorkspaceInviteTeam } from '@/workspace/components/WorkspaceInviteTeam';
|
||||||
import { WorkspaceMemberCard } from '@/workspace/components/WorkspaceMemberCard';
|
import { WorkspaceMemberCard } from '@/workspace/components/WorkspaceMemberCard';
|
||||||
import { WorkspaceMember } from '@/workspace-member/types/WorkspaceMember';
|
import { WorkspaceMember } from '@/workspace-member/types/WorkspaceMember';
|
||||||
|
|
||||||
@ -52,11 +53,18 @@ export const SettingsWorkspaceMembers = () => {
|
|||||||
<SubMenuTopBarContainer Icon={IconSettings} title="Settings">
|
<SubMenuTopBarContainer Icon={IconSettings} title="Settings">
|
||||||
<SettingsPageContainer>
|
<SettingsPageContainer>
|
||||||
<StyledH1Title title="Members" />
|
<StyledH1Title title="Members" />
|
||||||
|
<Section>
|
||||||
|
<H2Title
|
||||||
|
title="Invite by email"
|
||||||
|
description="Send an invite email to your team"
|
||||||
|
/>
|
||||||
|
<WorkspaceInviteTeam />
|
||||||
|
</Section>
|
||||||
{currentWorkspace?.inviteHash && (
|
{currentWorkspace?.inviteHash && (
|
||||||
<Section>
|
<Section>
|
||||||
<H2Title
|
<H2Title
|
||||||
title="Invite"
|
title="Or send an invite link"
|
||||||
description="Send an invitation to use Twenty"
|
description="Copy and send an invite link directly"
|
||||||
/>
|
/>
|
||||||
<WorkspaceInviteLink
|
<WorkspaceInviteLink
|
||||||
inviteLink={`${window.location.origin}/invite/${currentWorkspace?.inviteHash}`}
|
inviteLink={`${window.location.origin}/invite/${currentWorkspace?.inviteHash}`}
|
||||||
|
|||||||
@ -55,7 +55,7 @@ SIGN_IN_PREFILLED=true
|
|||||||
# SERVER_URL=http://localhost:3000
|
# SERVER_URL=http://localhost:3000
|
||||||
# WORKSPACE_INACTIVE_DAYS_BEFORE_NOTIFICATION=30
|
# WORKSPACE_INACTIVE_DAYS_BEFORE_NOTIFICATION=30
|
||||||
# WORKSPACE_INACTIVE_DAYS_BEFORE_DELETION=60
|
# WORKSPACE_INACTIVE_DAYS_BEFORE_DELETION=60
|
||||||
# Email Server Settings, see this doc for more info: https://docs.twenty.com/start/self-hosting/environment-variables
|
# Email Server Settings, see this doc for more info: https://docs.twenty.com/start/self-hosting/#email
|
||||||
# EMAIL_FROM_ADDRESS=contact@yourdomain.com
|
# EMAIL_FROM_ADDRESS=contact@yourdomain.com
|
||||||
# EMAIL_SYSTEM_ADDRESS=system@yourdomain.com
|
# EMAIL_SYSTEM_ADDRESS=system@yourdomain.com
|
||||||
# EMAIL_FROM_NAME='John from YourDomain'
|
# EMAIL_FROM_NAME='John from YourDomain'
|
||||||
|
|||||||
@ -0,0 +1,9 @@
|
|||||||
|
import { Field, ObjectType } from '@nestjs/graphql';
|
||||||
|
|
||||||
|
@ObjectType()
|
||||||
|
export class SendInviteLink {
|
||||||
|
@Field(() => Boolean, {
|
||||||
|
description: 'Boolean that confirms query was dispatched',
|
||||||
|
})
|
||||||
|
success: boolean;
|
||||||
|
}
|
||||||
@ -0,0 +1,12 @@
|
|||||||
|
import { ArgsType, Field } from '@nestjs/graphql';
|
||||||
|
|
||||||
|
import { ArrayNotEmpty, IsArray, IsEmail } from 'class-validator';
|
||||||
|
|
||||||
|
@ArgsType()
|
||||||
|
export class SendInviteLinkInput {
|
||||||
|
@Field(() => [String])
|
||||||
|
@IsArray()
|
||||||
|
@ArrayNotEmpty()
|
||||||
|
@IsEmail({}, { each: true })
|
||||||
|
emails: string[];
|
||||||
|
}
|
||||||
@ -8,6 +8,8 @@ import { User } from 'src/engine/core-modules/user/user.entity';
|
|||||||
import { BillingService } from 'src/engine/core-modules/billing/billing.service';
|
import { BillingService } from 'src/engine/core-modules/billing/billing.service';
|
||||||
import { UserWorkspaceService } from 'src/engine/core-modules/user-workspace/user-workspace.service';
|
import { UserWorkspaceService } from 'src/engine/core-modules/user-workspace/user-workspace.service';
|
||||||
import { UserService } from 'src/engine/core-modules/user/services/user.service';
|
import { UserService } from 'src/engine/core-modules/user/services/user.service';
|
||||||
|
import { EmailService } from 'src/engine/integrations/email/email.service';
|
||||||
|
import { EnvironmentService } from 'src/engine/integrations/environment/environment.service';
|
||||||
|
|
||||||
import { WorkspaceService } from './workspace.service';
|
import { WorkspaceService } from './workspace.service';
|
||||||
|
|
||||||
@ -46,6 +48,14 @@ describe('WorkspaceService', () => {
|
|||||||
provide: BillingService,
|
provide: BillingService,
|
||||||
useValue: {},
|
useValue: {},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
provide: EmailService,
|
||||||
|
useValue: {},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: EnvironmentService,
|
||||||
|
useValue: {},
|
||||||
|
},
|
||||||
],
|
],
|
||||||
}).compile();
|
}).compile();
|
||||||
|
|
||||||
|
|||||||
@ -5,6 +5,8 @@ import assert from 'assert';
|
|||||||
|
|
||||||
import { TypeOrmQueryService } from '@ptc-org/nestjs-query-typeorm';
|
import { TypeOrmQueryService } from '@ptc-org/nestjs-query-typeorm';
|
||||||
import { Repository } from 'typeorm';
|
import { Repository } from 'typeorm';
|
||||||
|
import { SendInviteLinkEmail } from 'twenty-emails';
|
||||||
|
import { render } from '@react-email/render';
|
||||||
|
|
||||||
import { WorkspaceManagerService } from 'src/engine/workspace-manager/workspace-manager.service';
|
import { WorkspaceManagerService } from 'src/engine/workspace-manager/workspace-manager.service';
|
||||||
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
|
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||||
@ -13,6 +15,9 @@ import { ActivateWorkspaceInput } from 'src/engine/core-modules/workspace/dtos/a
|
|||||||
import { UserWorkspace } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
|
import { UserWorkspace } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
|
||||||
import { UserWorkspaceService } from 'src/engine/core-modules/user-workspace/user-workspace.service';
|
import { UserWorkspaceService } from 'src/engine/core-modules/user-workspace/user-workspace.service';
|
||||||
import { BillingService } from 'src/engine/core-modules/billing/billing.service';
|
import { BillingService } from 'src/engine/core-modules/billing/billing.service';
|
||||||
|
import { SendInviteLink } from 'src/engine/core-modules/workspace/dtos/send-invite-link.entity';
|
||||||
|
import { EmailService } from 'src/engine/integrations/email/email.service';
|
||||||
|
import { EnvironmentService } from 'src/engine/integrations/environment/environment.service';
|
||||||
|
|
||||||
export class WorkspaceService extends TypeOrmQueryService<Workspace> {
|
export class WorkspaceService extends TypeOrmQueryService<Workspace> {
|
||||||
constructor(
|
constructor(
|
||||||
@ -25,6 +30,8 @@ export class WorkspaceService extends TypeOrmQueryService<Workspace> {
|
|||||||
private readonly workspaceManagerService: WorkspaceManagerService,
|
private readonly workspaceManagerService: WorkspaceManagerService,
|
||||||
private readonly userWorkspaceService: UserWorkspaceService,
|
private readonly userWorkspaceService: UserWorkspaceService,
|
||||||
private readonly billingService: BillingService,
|
private readonly billingService: BillingService,
|
||||||
|
private readonly environmentService: EnvironmentService,
|
||||||
|
private readonly emailService: EmailService,
|
||||||
) {
|
) {
|
||||||
super(workspaceRepository);
|
super(workspaceRepository);
|
||||||
}
|
}
|
||||||
@ -92,6 +99,46 @@ export class WorkspaceService extends TypeOrmQueryService<Workspace> {
|
|||||||
await this.reassignOrRemoveUserDefaultWorkspace(workspaceId, userId);
|
await this.reassignOrRemoveUserDefaultWorkspace(workspaceId, userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async sendInviteLink(
|
||||||
|
emails: string[],
|
||||||
|
workspace: Workspace,
|
||||||
|
sender: User,
|
||||||
|
): Promise<SendInviteLink> {
|
||||||
|
if (!workspace?.inviteHash) {
|
||||||
|
return { success: false };
|
||||||
|
}
|
||||||
|
const frontBaseURL = this.environmentService.get('FRONT_BASE_URL');
|
||||||
|
const inviteLink = `${frontBaseURL}/invite/${workspace.inviteHash}`;
|
||||||
|
|
||||||
|
for (const email of emails) {
|
||||||
|
const emailData = {
|
||||||
|
link: inviteLink,
|
||||||
|
workspace: { name: workspace.displayName, logo: workspace.logo },
|
||||||
|
sender: { email: sender.email, firstName: sender.firstName },
|
||||||
|
};
|
||||||
|
const emailTemplate = SendInviteLinkEmail(emailData);
|
||||||
|
const html = render(emailTemplate, {
|
||||||
|
pretty: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const text = render(emailTemplate, {
|
||||||
|
plainText: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.emailService.send({
|
||||||
|
from: `${this.environmentService.get(
|
||||||
|
'EMAIL_FROM_NAME',
|
||||||
|
)} <${this.environmentService.get('EMAIL_FROM_ADDRESS')}>`,
|
||||||
|
to: email,
|
||||||
|
subject: 'Join your team on Twenty',
|
||||||
|
text,
|
||||||
|
html,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
private async reassignOrRemoveUserDefaultWorkspace(
|
private async reassignOrRemoveUserDefaultWorkspace(
|
||||||
workspaceId: string,
|
workspaceId: string,
|
||||||
userId: string,
|
userId: string,
|
||||||
|
|||||||
@ -25,6 +25,8 @@ import { BillingSubscription } from 'src/engine/core-modules/billing/entities/bi
|
|||||||
import { BillingService } from 'src/engine/core-modules/billing/billing.service';
|
import { BillingService } from 'src/engine/core-modules/billing/billing.service';
|
||||||
import { DemoEnvGuard } from 'src/engine/guards/demo.env.guard';
|
import { DemoEnvGuard } from 'src/engine/guards/demo.env.guard';
|
||||||
import { WorkspaceCacheVersionService } from 'src/engine/metadata-modules/workspace-cache-version/workspace-cache-version.service';
|
import { WorkspaceCacheVersionService } from 'src/engine/metadata-modules/workspace-cache-version/workspace-cache-version.service';
|
||||||
|
import { SendInviteLink } from 'src/engine/core-modules/workspace/dtos/send-invite-link.entity';
|
||||||
|
import { SendInviteLinkInput } from 'src/engine/core-modules/workspace/dtos/send-invite-link.input';
|
||||||
|
|
||||||
import { Workspace } from './workspace.entity';
|
import { Workspace } from './workspace.entity';
|
||||||
|
|
||||||
@ -122,4 +124,17 @@ export class WorkspaceResolver {
|
|||||||
workspaceId: workspace.id,
|
workspaceId: workspace.id,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Mutation(() => SendInviteLink)
|
||||||
|
async sendInviteLink(
|
||||||
|
@Args() sendInviteLinkInput: SendInviteLinkInput,
|
||||||
|
@AuthUser() user: User,
|
||||||
|
@AuthWorkspace() workspace: Workspace,
|
||||||
|
): Promise<SendInviteLink> {
|
||||||
|
return await this.workspaceService.sendInviteLink(
|
||||||
|
sendInviteLinkInput.emails,
|
||||||
|
workspace,
|
||||||
|
user,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user