From e9d3ed99ca30dab4808eddc0e87e364eba018ba0 Mon Sep 17 00:00:00 2001 From: martmull Date: Wed, 5 Jun 2024 16:35:14 +0200 Subject: [PATCH] 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 ![image](https://github.com/twentyhq/twenty/assets/29927851/d569fc64-fa0c-4769-a3dd-1193a12b495c) --- .../docs/start/self-hosting/self-hosting.mdx | 17 +-- packages/twenty-emails/src/common-style.ts | 10 +- .../twenty-emails/src/components/BaseHead.tsx | 6 +- .../src/components/CallToAction.tsx | 2 +- .../src/components/HighlightedContainer.tsx | 33 ++++ .../src/components/HighlightedText.tsx | 24 ++- .../twenty-emails/src/components/MainText.tsx | 3 + .../src/components/ShadowText.tsx | 16 ++ .../twenty-emails/src/components/SubTitle.tsx | 23 +++ .../twenty-emails/src/components/Title.tsx | 17 ++- .../src/components/WhatIsTwenty.tsx | 47 ++++++ .../src/emails/send-invite-link.email.tsx | 44 ++++++ packages/twenty-emails/src/index.ts | 1 + .../twenty-emails/src/utils/capitalize.ts | 7 + .../src/generated-metadata/graphql.ts | 13 ++ .../twenty-front/src/generated/graphql.tsx | 53 +++++++ .../ui/input/components/TextInputV2.tsx | 34 ++++- .../components/WorkspaceInviteTeam.tsx | 143 ++++++++++++++++++ .../graphql/mutations/sendInviteLink.ts | 9 ++ .../utils/__tests__/extractEmailList.test.ts | 28 ++++ .../workspace/utils/extractEmailList.ts | 10 ++ .../settings/SettingsWorkspaceMembers.tsx | 12 +- packages/twenty-server/.env.example | 2 +- .../workspace/dtos/send-invite-link.entity.ts | 9 ++ .../workspace/dtos/send-invite-link.input.ts | 12 ++ .../services/workspace.service.spec.ts | 10 ++ .../workspace/services/workspace.service.ts | 47 ++++++ .../workspace/workspace.resolver.ts | 15 ++ 28 files changed, 608 insertions(+), 39 deletions(-) create mode 100644 packages/twenty-emails/src/components/HighlightedContainer.tsx create mode 100644 packages/twenty-emails/src/components/ShadowText.tsx create mode 100644 packages/twenty-emails/src/components/SubTitle.tsx create mode 100644 packages/twenty-emails/src/components/WhatIsTwenty.tsx create mode 100644 packages/twenty-emails/src/emails/send-invite-link.email.tsx create mode 100644 packages/twenty-emails/src/utils/capitalize.ts create mode 100644 packages/twenty-front/src/modules/workspace/components/WorkspaceInviteTeam.tsx create mode 100644 packages/twenty-front/src/modules/workspace/graphql/mutations/sendInviteLink.ts create mode 100644 packages/twenty-front/src/modules/workspace/utils/__tests__/extractEmailList.test.ts create mode 100644 packages/twenty-front/src/modules/workspace/utils/extractEmailList.ts create mode 100644 packages/twenty-server/src/engine/core-modules/workspace/dtos/send-invite-link.entity.ts create mode 100644 packages/twenty-server/src/engine/core-modules/workspace/dtos/send-invite-link.input.ts 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 ( + + + <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> + ); +}; 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<Scalars['String']['output']>; 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<Scalars['String']['input']>; +}; + + export type MutationSignUpArgs = { captchaToken?: InputMaybe<Scalars['String']['input']>; 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<Scalars['String']['output']>; 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<Scalars['String']>; +}; + + export type MutationSignUpArgs = { captchaToken?: InputMaybe<Scalars['String']>; email: Scalars['String']; @@ -584,6 +590,7 @@ export type RemoteServer = { foreignDataWrapperOptions?: Maybe<Scalars['JSON']>; foreignDataWrapperType: Scalars['String']; id: Scalars['ID']; + label: Scalars['String']; schema?: Maybe<Scalars['String']>; updatedAt: Scalars['DateTime']; userMappingOptions?: Maybe<UserMappingOptionsUser>; @@ -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<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 SendInviteLinkMutationVariables = Exact<{ + emails: Array<Scalars['String']> | 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<typeof useDeleteCurrentWorkspaceMutation>; export type DeleteCurrentWorkspaceMutationResult = Apollo.MutationResult<DeleteCurrentWorkspaceMutation>; 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` 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<Pick<TextInputV2ComponentProps, 'fullWidth'>>` +const StyledInput = styled.input< + Pick<TextInputV2ComponentProps, 'fullWidth' | 'LeftIcon'> +>` 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<HTMLInputElement>) => void; onBlur?: FocusEventHandler<HTMLInputElement>; }; @@ -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 = ( <StyledContainer className={className} fullWidth={fullWidth ?? false}> {label && <StyledLabel>{label + (required ? '*' : '')}</StyledLabel>} <StyledInputContainer> + {!!LeftIcon && ( + <StyledLeftIconContainer> + <StyledTrailingIcon> + <LeftIcon size={theme.icon.size.md} /> + </StyledTrailingIcon> + </StyledLeftIconContainer> + )} <StyledInput autoComplete={autoComplete || 'off'} ref={combinedRef} @@ -154,7 +180,7 @@ const TextInputV2Component = ( onChange?.(event.target.value); }} onKeyDown={onKeyDown} - {...{ autoFocus, disabled, placeholder, required, value }} + {...{ autoFocus, disabled, placeholder, required, value, LeftIcon }} /> <StyledTrailingIconContainer> {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<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> + ); +}; 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 = () => { <SubMenuTopBarContainer Icon={IconSettings} title="Settings"> <SettingsPageContainer> <StyledH1Title title="Members" /> + <Section> + <H2Title + title="Invite by email" + description="Send an invite email to your team" + /> + <WorkspaceInviteTeam /> + </Section> {currentWorkspace?.inviteHash && ( <Section> <H2Title - title="Invite" - description="Send an invitation to use Twenty" + title="Or send an invite link" + description="Copy and send an invite link directly" /> <WorkspaceInviteLink inviteLink={`${window.location.origin}/invite/${currentWorkspace?.inviteHash}`} diff --git a/packages/twenty-server/.env.example b/packages/twenty-server/.env.example index f7ecf57c3..bfe76f8a4 100644 --- a/packages/twenty-server/.env.example +++ b/packages/twenty-server/.env.example @@ -55,7 +55,7 @@ SIGN_IN_PREFILLED=true # SERVER_URL=http://localhost:3000 # WORKSPACE_INACTIVE_DAYS_BEFORE_NOTIFICATION=30 # 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_SYSTEM_ADDRESS=system@yourdomain.com # EMAIL_FROM_NAME='John from YourDomain' diff --git a/packages/twenty-server/src/engine/core-modules/workspace/dtos/send-invite-link.entity.ts b/packages/twenty-server/src/engine/core-modules/workspace/dtos/send-invite-link.entity.ts new file mode 100644 index 000000000..bd3d11607 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/workspace/dtos/send-invite-link.entity.ts @@ -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; +} 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<Workspace> { constructor( @@ -25,6 +30,8 @@ export class WorkspaceService extends TypeOrmQueryService<Workspace> { 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<Workspace> { 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( 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<SendInviteLink> { + return await this.workspaceService.sendInviteLink( + sendInviteLinkInput.emails, + workspace, + user, + ); + } }