diff --git a/packages/twenty-docs/docs/start/self-hosting/self-hosting.mdx b/packages/twenty-docs/docs/start/self-hosting/self-hosting.mdx
index de75c17e7..13b2aff38 100644
--- a/packages/twenty-docs/docs/start/self-hosting/self-hosting.mdx
+++ b/packages/twenty-docs/docs/start/self-hosting/self-hosting.mdx
@@ -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).
- EMAIL_SMTP_HOST=smtp.gmail.com
- - EMAIL_SERVER_PORT=465
- - EMAIL_SERVER_USER=gmail_email_address
- - EMAIL_SERVER_PASSWORD='gmail_app_password'
+ - EMAIL_SMTP_PORT=465
+ - EMAIL_SMTP_USER=gmail_email_address
+ - EMAIL_SMTP_PASSWORD='gmail_app_password'
@@ -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).
- EMAIL_SMTP_HOST=smtp.office365.com
- - EMAIL_SERVER_PORT=587
- - EMAIL_SERVER_USER=office365_email_address
- - EMAIL_SERVER_PASSWORD='office365_password'
+ - EMAIL_SMTP_PORT=587
+ - EMAIL_SMTP_USER=office365_email_address
+ - EMAIL_SMTP_PASSWORD='office365_password'
@@ -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`
- Access the smtp4dev ui here: [http://localhost:8090](http://localhost:8090)
- Set the following env variables:
- - EMAIL_SERVER_HOST=localhost
- - EMAIL_SERVER_PORT=2525
+ - EMAIL_SMTP_HOST=localhost
+ - EMAIL_SMTP_PORT=2525
@@ -218,4 +218,3 @@ yarn command:prod cron:messaging:message-list-fetch
['CAPTCHA_SITE_KEY', '', 'The captcha site key'],
['CAPTCHA_SECRET_KEY', '', 'The captcha secret key'],
]}>
-
diff --git a/packages/twenty-emails/src/common-style.ts b/packages/twenty-emails/src/common-style.ts
index 8ebf9154c..aa1419666 100644
--- a/packages/twenty-emails/src/common-style.ts
+++ b/packages/twenty-emails/src/common-style.ts
@@ -27,17 +27,25 @@ export const emailTheme = {
colors: {
highlighted: grayScale.gray60,
primary: grayScale.gray50,
- tertiary: grayScale.gray40,
+ tertiary: grayScale.gray35,
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: {
regular: 400,
bold: 600,
},
size: {
+ sm: '12px',
md: '13px',
lg: '16px',
+ xl: '24px',
},
+ lineHeight: '20px',
+ },
+ border: {
+ radius: { sm: '4px', md: '8px' },
+ color: { highlighted: grayScale.gray20 },
},
background: {
colors: { highlight: grayScale.gray15 },
diff --git a/packages/twenty-emails/src/components/BaseHead.tsx b/packages/twenty-emails/src/components/BaseHead.tsx
index 559d9419b..389369b26 100644
--- a/packages/twenty-emails/src/components/BaseHead.tsx
+++ b/packages/twenty-emails/src/components/BaseHead.tsx
@@ -7,12 +7,8 @@ export const BaseHead = () => {
Twenty email
diff --git a/packages/twenty-emails/src/components/CallToAction.tsx b/packages/twenty-emails/src/components/CallToAction.tsx
index 7df247d54..f8ffad7b7 100644
--- a/packages/twenty-emails/src/components/CallToAction.tsx
+++ b/packages/twenty-emails/src/components/CallToAction.tsx
@@ -6,7 +6,7 @@ import { emailTheme } from 'src/common-style';
const callToActionStyle = {
display: 'flex',
padding: '8px 32px',
- borderRadius: '8px',
+ borderRadius: emailTheme.border.radius.md,
border: `1px solid ${emailTheme.background.transparent.light}`,
background: emailTheme.background.radialGradient,
boxShadow: `0px 2px 4px 0px ${emailTheme.background.transparent.light}, 0px 0px 4px 0px ${emailTheme.background.transparent.medium}`,
diff --git a/packages/twenty-emails/src/components/HighlightedContainer.tsx b/packages/twenty-emails/src/components/HighlightedContainer.tsx
new file mode 100644
index 000000000..c9a40dac7
--- /dev/null
+++ b/packages/twenty-emails/src/components/HighlightedContainer.tsx
@@ -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 (
+
+ {children}
+
+ );
+};
diff --git a/packages/twenty-emails/src/components/HighlightedText.tsx b/packages/twenty-emails/src/components/HighlightedText.tsx
index 1e40ccd19..b60444158 100644
--- a/packages/twenty-emails/src/components/HighlightedText.tsx
+++ b/packages/twenty-emails/src/components/HighlightedText.tsx
@@ -1,34 +1,30 @@
-import { ReactNode } from 'react';
-import { Column } from '@react-email/components';
-import { Row } from '@react-email/row';
+import React, { ReactNode } from 'react';
import { Text } from '@react-email/text';
import { emailTheme } from 'src/common-style';
-const rowStyle = {
- display: 'flex',
-};
-
const highlightedStyle = {
- borderRadius: '4px',
+ borderRadius: emailTheme.border.radius.sm,
background: emailTheme.background.colors.highlight,
padding: '4px 8px',
- margin: 0,
fontSize: emailTheme.font.size.lg,
fontWeight: emailTheme.font.weight.bold,
color: emailTheme.font.colors.highlighted,
};
+const divStyle = {
+ display: 'flex',
+};
+
type HighlightedTextProps = {
value: ReactNode;
+ centered?: boolean;
};
export const HighlightedText = ({ value }: HighlightedTextProps) => {
return (
-
-
- {value}
-
-
+
+ {value}
+
);
};
diff --git a/packages/twenty-emails/src/components/MainText.tsx b/packages/twenty-emails/src/components/MainText.tsx
index fee31bff2..77b087531 100644
--- a/packages/twenty-emails/src/components/MainText.tsx
+++ b/packages/twenty-emails/src/components/MainText.tsx
@@ -4,9 +4,12 @@ import { Text } from '@react-email/text';
import { emailTheme } from 'src/common-style';
const mainTextStyle = {
+ fontFamily: emailTheme.font.family,
fontSize: emailTheme.font.size.md,
fontWeight: emailTheme.font.weight.regular,
color: emailTheme.font.colors.primary,
+ margin: '0 0 12px 0',
+ lineHeight: emailTheme.font.lineHeight,
};
export const MainText = ({ children }: MainTextProps) => {
diff --git a/packages/twenty-emails/src/components/ShadowText.tsx b/packages/twenty-emails/src/components/ShadowText.tsx
new file mode 100644
index 000000000..25a46ef98
--- /dev/null
+++ b/packages/twenty-emails/src/components/ShadowText.tsx
@@ -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 {children};
+};
diff --git a/packages/twenty-emails/src/components/SubTitle.tsx b/packages/twenty-emails/src/components/SubTitle.tsx
new file mode 100644
index 000000000..6bbbc180b
--- /dev/null
+++ b/packages/twenty-emails/src/components/SubTitle.tsx
@@ -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 (
+
+ {value}
+
+ );
+};
diff --git a/packages/twenty-emails/src/components/Title.tsx b/packages/twenty-emails/src/components/Title.tsx
index 66adcfc33..3b5442810 100644
--- a/packages/twenty-emails/src/components/Title.tsx
+++ b/packages/twenty-emails/src/components/Title.tsx
@@ -1,10 +1,23 @@
import { ReactNode } from 'react';
import { Heading } from '@react-email/components';
+import { emailTheme } from 'src/common-style';
+
type TitleProps = {
value: ReactNode;
};
-export const Title = ({ value }: TitleProps) => {
- return {value};
+const titleStyle = {
+ 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 (
+
+ {value}
+
+ );
};
diff --git a/packages/twenty-emails/src/components/WhatIsTwenty.tsx b/packages/twenty-emails/src/components/WhatIsTwenty.tsx
new file mode 100644
index 000000000..3a380385e
--- /dev/null
+++ b/packages/twenty-emails/src/components/WhatIsTwenty.tsx
@@ -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 (
+ <>
+
+
+ A software to help businesses manage their customer data and
+ relationships efficiently.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Twenty.com Public Benefit Corporation
+
+ 2261 Market Street #5275
+
+ San Francisco, CA 94114
+
+ >
+ );
+};
diff --git a/packages/twenty-emails/src/emails/send-invite-link.email.tsx b/packages/twenty-emails/src/emails/send-invite-link.email.tsx
new file mode 100644
index 000000000..3fefac277
--- /dev/null
+++ b/packages/twenty-emails/src/emails/send-invite-link.email.tsx
@@ -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 (
+
+
+
+ {capitalize(sender.firstName)} (
+ ) has invited you to
+ join a workspace called {workspace.name}
+
+
+
+ {workspace.logo &&
}
+ {workspace.name && }
+
+
+
+
+ );
+};
diff --git a/packages/twenty-emails/src/index.ts b/packages/twenty-emails/src/index.ts
index 429bd0b06..ddecb05c8 100644
--- a/packages/twenty-emails/src/index.ts
+++ b/packages/twenty-emails/src/index.ts
@@ -2,3 +2,4 @@ export * from './emails/clean-inactive-workspaces.email';
export * from './emails/delete-inactive-workspaces.email';
export * from './emails/password-reset-link.email';
export * from './emails/password-update-notify.email';
+export * from './emails/send-invite-link.email';
diff --git a/packages/twenty-emails/src/utils/capitalize.ts b/packages/twenty-emails/src/utils/capitalize.ts
new file mode 100644
index 000000000..9953f3751
--- /dev/null
+++ b/packages/twenty-emails/src/utils/capitalize.ts
@@ -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);
+};
diff --git a/packages/twenty-front/src/generated-metadata/graphql.ts b/packages/twenty-front/src/generated-metadata/graphql.ts
index 65392ce77..e91791af9 100644
--- a/packages/twenty-front/src/generated-metadata/graphql.ts
+++ b/packages/twenty-front/src/generated-metadata/graphql.ts
@@ -139,6 +139,7 @@ export type ClientConfig = {
authProviders: AuthProviders;
billing: Billing;
captcha: Captcha;
+ chromeExtensionId?: Maybe;
debugMode: Scalars['Boolean']['output'];
sentry: Sentry;
signInPrefilled: Scalars['Boolean']['output'];
@@ -391,6 +392,7 @@ export type Mutation = {
generateTransientToken: TransientToken;
impersonate: Verify;
renewToken: AuthTokens;
+ sendInviteLink: SendInviteLink;
signUp: LoginToken;
syncRemoteTable: RemoteTable;
syncRemoteTableSchemaChanges: RemoteTable;
@@ -518,6 +520,11 @@ export type MutationRenewTokenArgs = {
};
+export type MutationSendInviteLinkArgs = {
+ emails: Array;
+};
+
+
export type MutationSignUpArgs = {
captchaToken?: InputMaybe;
email: Scalars['String']['input'];
@@ -849,6 +856,12 @@ export enum RemoteTableStatus {
Synced = 'SYNCED'
}
+export type SendInviteLink = {
+ __typename?: 'SendInviteLink';
+ /** Boolean that confirms query was dispatched */
+ success: Scalars['Boolean']['output'];
+};
+
export type Sentry = {
__typename?: 'Sentry';
dsn?: Maybe;
diff --git a/packages/twenty-front/src/generated/graphql.tsx b/packages/twenty-front/src/generated/graphql.tsx
index dbe37ed4d..0a3ee2229 100644
--- a/packages/twenty-front/src/generated/graphql.tsx
+++ b/packages/twenty-front/src/generated/graphql.tsx
@@ -285,6 +285,7 @@ export type Mutation = {
generateTransientToken: TransientToken;
impersonate: Verify;
renewToken: AuthTokens;
+ sendInviteLink: SendInviteLink;
signUp: LoginToken;
track: Analytics;
updateBillingSubscription: UpdateBillingEntity;
@@ -367,6 +368,11 @@ export type MutationRenewTokenArgs = {
};
+export type MutationSendInviteLinkArgs = {
+ emails: Array;
+};
+
+
export type MutationSignUpArgs = {
captchaToken?: InputMaybe;
email: Scalars['String'];
@@ -584,6 +590,7 @@ export type RemoteServer = {
foreignDataWrapperOptions?: Maybe;
foreignDataWrapperType: Scalars['String'];
id: Scalars['ID'];
+ label: Scalars['String'];
schema?: Maybe;
updatedAt: Scalars['DateTime'];
userMappingOptions?: Maybe;
@@ -604,6 +611,12 @@ export enum RemoteTableStatus {
Synced = 'SYNCED'
}
+export type SendInviteLink = {
+ __typename?: 'SendInviteLink';
+ /** Boolean that confirms query was dispatched */
+ success: Scalars['Boolean'];
+};
+
export type Sentry = {
__typename?: 'Sentry';
dsn?: Maybe;
@@ -1227,6 +1240,13 @@ export type DeleteCurrentWorkspaceMutationVariables = Exact<{ [key: string]: nev
export type DeleteCurrentWorkspaceMutation = { __typename?: 'Mutation', deleteCurrentWorkspace: { __typename?: 'Workspace', id: any } };
+export type SendInviteLinkMutationVariables = Exact<{
+ emails: Array | Scalars['String'];
+}>;
+
+
+export type SendInviteLinkMutation = { __typename?: 'Mutation', sendInviteLink: { __typename?: 'SendInviteLink', success: boolean } };
+
export type UpdateWorkspaceMutationVariables = Exact<{
input: UpdateWorkspaceInput;
}>;
@@ -2579,6 +2599,39 @@ export function useDeleteCurrentWorkspaceMutation(baseOptions?: Apollo.MutationH
export type DeleteCurrentWorkspaceMutationHookResult = ReturnType;
export type DeleteCurrentWorkspaceMutationResult = Apollo.MutationResult;
export type DeleteCurrentWorkspaceMutationOptions = Apollo.BaseMutationOptions;
+export const SendInviteLinkDocument = gql`
+ mutation SendInviteLink($emails: [String!]!) {
+ sendInviteLink(emails: $emails) {
+ success
+ }
+}
+ `;
+export type SendInviteLinkMutationFn = Apollo.MutationFunction;
+
+/**
+ * __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) {
+ const options = {...defaultOptions, ...baseOptions}
+ return Apollo.useMutation(SendInviteLinkDocument, options);
+ }
+export type SendInviteLinkMutationHookResult = ReturnType;
+export type SendInviteLinkMutationResult = Apollo.MutationResult;
+export type SendInviteLinkMutationOptions = Apollo.BaseMutationOptions;
export const UpdateWorkspaceDocument = gql`
mutation UpdateWorkspace($input: UpdateWorkspaceInput!) {
updateWorkspace(data: $input) {
diff --git a/packages/twenty-front/src/modules/ui/input/components/TextInputV2.tsx b/packages/twenty-front/src/modules/ui/input/components/TextInputV2.tsx
index e75b2c865..beb8586a4 100644
--- a/packages/twenty-front/src/modules/ui/input/components/TextInputV2.tsx
+++ b/packages/twenty-front/src/modules/ui/input/components/TextInputV2.tsx
@@ -34,12 +34,17 @@ const StyledInputContainer = styled.div`
width: 100%;
`;
-const StyledInput = styled.input>`
+const StyledInput = styled.input<
+ Pick
+>`
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-bottom-left-radius: ${({ theme, LeftIcon }) =>
+ !LeftIcon && theme.border.radius.sm};
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;
color: ${({ theme }) => theme.font.color.primary};
display: flex;
@@ -69,6 +74,18 @@ const StyledErrorHelper = styled.div`
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`
align-items: center;
background-color: ${({ theme }) => theme.background.transparent.lighter};
@@ -101,6 +118,7 @@ export type TextInputV2ComponentProps = Omit<
fullWidth?: boolean;
error?: string;
RightIcon?: IconComponent;
+ LeftIcon?: IconComponent;
onKeyDown?: (event: React.KeyboardEvent) => void;
onBlur?: FocusEventHandler;
};
@@ -123,6 +141,7 @@ const TextInputV2Component = (
disabled,
tabIndex,
RightIcon,
+ LeftIcon,
autoComplete,
}: TextInputV2ComponentProps,
// eslint-disable-next-line @nx/workspace-component-props-naming
@@ -143,6 +162,13 @@ const TextInputV2Component = (
{label && {label + (required ? '*' : '')}}
+ {!!LeftIcon && (
+
+
+
+
+
+ )}
{error && (
diff --git a/packages/twenty-front/src/modules/workspace/components/WorkspaceInviteTeam.tsx b/packages/twenty-front/src/modules/workspace/components/WorkspaceInviteTeam.tsx
new file mode 100644
index 000000000..e0490363f
--- /dev/null
+++ b/packages/twenty-front/src/modules/workspace/components/WorkspaceInviteTeam.tsx
@@ -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({
+ 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: ,
+ duration: 2000,
+ });
+ });
+
+ const handleKeyDown = (e: React.KeyboardEvent) => {
+ if (e.key === Key.Enter) {
+ submit();
+ }
+ };
+
+ const { isSubmitSuccessful } = formState;
+
+ useEffect(() => {
+ if (isSubmitSuccessful) {
+ reset();
+ }
+ }, [isSubmitSuccessful, reset]);
+
+ return (
+
+ );
+};
diff --git a/packages/twenty-front/src/modules/workspace/graphql/mutations/sendInviteLink.ts b/packages/twenty-front/src/modules/workspace/graphql/mutations/sendInviteLink.ts
new file mode 100644
index 000000000..c34f4734c
--- /dev/null
+++ b/packages/twenty-front/src/modules/workspace/graphql/mutations/sendInviteLink.ts
@@ -0,0 +1,9 @@
+import { gql } from '@apollo/client';
+
+export const SEND_INVITE_LINK = gql`
+ mutation SendInviteLink($emails: [String!]!) {
+ sendInviteLink(emails: $emails) {
+ success
+ }
+ }
+`;
diff --git a/packages/twenty-front/src/modules/workspace/utils/__tests__/extractEmailList.test.ts b/packages/twenty-front/src/modules/workspace/utils/__tests__/extractEmailList.test.ts
new file mode 100644
index 000000000..d9b24cab1
--- /dev/null
+++ b/packages/twenty-front/src/modules/workspace/utils/__tests__/extractEmailList.test.ts
@@ -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']);
+ });
+});
diff --git a/packages/twenty-front/src/modules/workspace/utils/extractEmailList.ts b/packages/twenty-front/src/modules/workspace/utils/extractEmailList.ts
new file mode 100644
index 000000000..caefe8069
--- /dev/null
+++ b/packages/twenty-front/src/modules/workspace/utils/extractEmailList.ts
@@ -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),
+ ),
+ );
+};
diff --git a/packages/twenty-front/src/pages/settings/SettingsWorkspaceMembers.tsx b/packages/twenty-front/src/pages/settings/SettingsWorkspaceMembers.tsx
index f020885f8..33215e7d0 100644
--- a/packages/twenty-front/src/pages/settings/SettingsWorkspaceMembers.tsx
+++ b/packages/twenty-front/src/pages/settings/SettingsWorkspaceMembers.tsx
@@ -14,6 +14,7 @@ import { ConfirmationModal } from '@/ui/layout/modal/components/ConfirmationModa
import { SubMenuTopBarContainer } from '@/ui/layout/page/SubMenuTopBarContainer';
import { Section } from '@/ui/layout/section/components/Section';
import { WorkspaceInviteLink } from '@/workspace/components/WorkspaceInviteLink';
+import { WorkspaceInviteTeam } from '@/workspace/components/WorkspaceInviteTeam';
import { WorkspaceMemberCard } from '@/workspace/components/WorkspaceMemberCard';
import { WorkspaceMember } from '@/workspace-member/types/WorkspaceMember';
@@ -52,11 +53,18 @@ export const SettingsWorkspaceMembers = () => {
+
{currentWorkspace?.inviteHash && (
Boolean, {
+ description: 'Boolean that confirms query was dispatched',
+ })
+ success: boolean;
+}
diff --git a/packages/twenty-server/src/engine/core-modules/workspace/dtos/send-invite-link.input.ts b/packages/twenty-server/src/engine/core-modules/workspace/dtos/send-invite-link.input.ts
new file mode 100644
index 000000000..2fb71f8b5
--- /dev/null
+++ b/packages/twenty-server/src/engine/core-modules/workspace/dtos/send-invite-link.input.ts
@@ -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[];
+}
diff --git a/packages/twenty-server/src/engine/core-modules/workspace/services/workspace.service.spec.ts b/packages/twenty-server/src/engine/core-modules/workspace/services/workspace.service.spec.ts
index 8b6dbbfd1..7c403f732 100644
--- a/packages/twenty-server/src/engine/core-modules/workspace/services/workspace.service.spec.ts
+++ b/packages/twenty-server/src/engine/core-modules/workspace/services/workspace.service.spec.ts
@@ -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 { UserWorkspaceService } from 'src/engine/core-modules/user-workspace/user-workspace.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';
@@ -46,6 +48,14 @@ describe('WorkspaceService', () => {
provide: BillingService,
useValue: {},
},
+ {
+ provide: EmailService,
+ useValue: {},
+ },
+ {
+ provide: EnvironmentService,
+ useValue: {},
+ },
],
}).compile();
diff --git a/packages/twenty-server/src/engine/core-modules/workspace/services/workspace.service.ts b/packages/twenty-server/src/engine/core-modules/workspace/services/workspace.service.ts
index f4248d467..38e7c2e04 100644
--- a/packages/twenty-server/src/engine/core-modules/workspace/services/workspace.service.ts
+++ b/packages/twenty-server/src/engine/core-modules/workspace/services/workspace.service.ts
@@ -5,6 +5,8 @@ import assert from 'assert';
import { TypeOrmQueryService } from '@ptc-org/nestjs-query-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 { 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 { UserWorkspaceService } from 'src/engine/core-modules/user-workspace/user-workspace.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 {
constructor(
@@ -25,6 +30,8 @@ export class WorkspaceService extends TypeOrmQueryService {
private readonly workspaceManagerService: WorkspaceManagerService,
private readonly userWorkspaceService: UserWorkspaceService,
private readonly billingService: BillingService,
+ private readonly environmentService: EnvironmentService,
+ private readonly emailService: EmailService,
) {
super(workspaceRepository);
}
@@ -92,6 +99,46 @@ export class WorkspaceService extends TypeOrmQueryService {
await this.reassignOrRemoveUserDefaultWorkspace(workspaceId, userId);
}
+ async sendInviteLink(
+ emails: string[],
+ workspace: Workspace,
+ sender: User,
+ ): Promise {
+ 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(
workspaceId: string,
userId: string,
diff --git a/packages/twenty-server/src/engine/core-modules/workspace/workspace.resolver.ts b/packages/twenty-server/src/engine/core-modules/workspace/workspace.resolver.ts
index 1a11675b4..a30853ac2 100644
--- a/packages/twenty-server/src/engine/core-modules/workspace/workspace.resolver.ts
+++ b/packages/twenty-server/src/engine/core-modules/workspace/workspace.resolver.ts
@@ -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 { DemoEnvGuard } from 'src/engine/guards/demo.env.guard';
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';
@@ -122,4 +124,17 @@ export class WorkspaceResolver {
workspaceId: workspace.id,
});
}
+
+ @Mutation(() => SendInviteLink)
+ async sendInviteLink(
+ @Args() sendInviteLinkInput: SendInviteLinkInput,
+ @AuthUser() user: User,
+ @AuthWorkspace() workspace: Workspace,
+ ): Promise {
+ return await this.workspaceService.sendInviteLink(
+ sendInviteLinkInput.emails,
+ workspace,
+ user,
+ );
+ }
}