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:
@ -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']>;
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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 && (
|
||||
|
||||
@ -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 { 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}`}
|
||||
|
||||
Reference in New Issue
Block a user