feat(invitation): Improve invitation flow - Milestone 2 (#6804)

From PR: #6626 
Resolves #6763 
Resolves #6055 
Resolves #6782

## GTK
I retain the 'Invite by link' feature to prevent any breaking changes.
We could make the invitation by link optional through an admin setting,
allowing users to rely solely on personal invitations.

## Todo
- [x] Add an expiration date to an invitation
- [x] Allow to renew an invitation to postpone the expiration date
- [x] Refresh the UI
- [x] Add the new personal token in the link sent to new user
- [x] Display an error if a user tries to use an expired invitation
- [x] Display an error if a user uses another mail than the one in the
invitation

---------

Co-authored-by: Charles Bochet <charles@twenty.com>
This commit is contained in:
Antoine Moreaux
2024-09-18 23:27:31 +02:00
committed by GitHub
co-authored by Charles Bochet
parent ad18c44f25
commit 89c97993e3
81 changed files with 1726 additions and 363 deletions
@@ -5,12 +5,14 @@ export const SIGN_UP = gql`
$email: String!
$password: String!
$workspaceInviteHash: String
$workspacePersonalInviteToken: String = null
$captchaToken: String
) {
signUp(
email: $email
password: $password
workspaceInviteHash: $workspaceInviteHash
workspacePersonalInviteToken: $workspacePersonalInviteToken
captchaToken: $captchaToken
) {
loginToken {
@@ -264,6 +264,7 @@ export const useAuth = () => {
email: string,
password: string,
workspaceInviteHash?: string,
workspacePersonalInviteToken?: string,
captchaToken?: string,
) => {
setIsVerifyPendingState(true);
@@ -273,6 +274,7 @@ export const useAuth = () => {
email,
password,
workspaceInviteHash,
workspacePersonalInviteToken,
captchaToken,
},
});
@@ -296,21 +298,43 @@ export const useAuth = () => {
[setIsVerifyPendingState, signUp, handleVerify],
);
const handleGoogleLogin = useCallback((workspaceInviteHash?: string) => {
const buildRedirectUrl = (
path: string,
params: {
workspacePersonalInviteToken?: string;
workspaceInviteHash?: string;
},
) => {
const authServerUrl = REACT_APP_SERVER_BASE_URL;
window.location.href =
`${authServerUrl}/auth/google/${
workspaceInviteHash ? '?inviteHash=' + workspaceInviteHash : ''
}` || '';
}, []);
const url = new URL(`${authServerUrl}${path}`);
if (isDefined(params.workspaceInviteHash)) {
url.searchParams.set('inviteHash', params.workspaceInviteHash);
}
if (isDefined(params.workspacePersonalInviteToken)) {
url.searchParams.set('inviteToken', params.workspacePersonalInviteToken);
}
return url.toString();
};
const handleMicrosoftLogin = useCallback((workspaceInviteHash?: string) => {
const authServerUrl = REACT_APP_SERVER_BASE_URL;
window.location.href =
`${authServerUrl}/auth/microsoft/${
workspaceInviteHash ? '?inviteHash=' + workspaceInviteHash : ''
}` || '';
}, []);
const handleGoogleLogin = useCallback(
(params: {
workspacePersonalInviteToken?: string;
workspaceInviteHash?: string;
}) => {
window.location.href = buildRedirectUrl('/auth/google', params);
},
[],
);
const handleMicrosoftLogin = useCallback(
(params: {
workspacePersonalInviteToken?: string;
workspaceInviteHash?: string;
}) => {
window.location.href = buildRedirectUrl('/auth/microsoft', params);
},
[],
);
return {
challenge: handleChallenge,
@@ -1,6 +1,6 @@
import { useCallback, useState } from 'react';
import { SubmitHandler, UseFormReturn } from 'react-hook-form';
import { useParams } from 'react-router-dom';
import { useParams, useSearchParams } from 'react-router-dom';
import { Form } from '@/auth/sign-in-up/hooks/useSignInUpForm';
import { useReadCaptchaToken } from '@/captcha/hooks/useReadCaptchaToken';
@@ -29,6 +29,9 @@ export const useSignInUp = (form: UseFormReturn<Form>) => {
const isMatchingLocation = useIsMatchingLocation();
const workspaceInviteHash = useParams().workspaceInviteHash;
const [searchParams] = useSearchParams();
const workspacePersonalInviteToken =
searchParams.get('inviteToken') ?? undefined;
const [isInviteMode] = useState(() => isMatchingLocation(AppPath.Invite));
@@ -112,6 +115,7 @@ export const useSignInUp = (form: UseFormReturn<Form>) => {
data.email.toLowerCase().trim(),
data.password,
workspaceInviteHash,
workspacePersonalInviteToken,
token,
);
} catch (err: any) {
@@ -128,6 +132,7 @@ export const useSignInUp = (form: UseFormReturn<Form>) => {
signInWithCredentials,
signUpWithCredentials,
workspaceInviteHash,
workspacePersonalInviteToken,
enqueueSnackBar,
requestFreshCaptchaToken,
],
@@ -1,9 +1,15 @@
import { useParams } from 'react-router-dom';
import { useParams, useSearchParams } from 'react-router-dom';
import { useAuth } from '@/auth/hooks/useAuth';
export const useSignInWithGoogle = () => {
const workspaceInviteHash = useParams().workspaceInviteHash;
const [searchParams] = useSearchParams();
const workspacePersonalInviteToken =
searchParams.get('inviteToken') ?? undefined;
const { signInWithGoogle } = useAuth();
return { signInWithGoogle: () => signInWithGoogle(workspaceInviteHash) };
return {
signInWithGoogle: () =>
signInWithGoogle({ workspaceInviteHash, workspacePersonalInviteToken }),
};
};
@@ -1,11 +1,18 @@
import { useParams } from 'react-router-dom';
import { useParams, useSearchParams } from 'react-router-dom';
import { useAuth } from '@/auth/hooks/useAuth';
export const useSignInWithMicrosoft = () => {
const workspaceInviteHash = useParams().workspaceInviteHash;
const [searchParams] = useSearchParams();
const workspacePersonalInviteToken =
searchParams.get('inviteToken') ?? undefined;
const { signInWithMicrosoft } = useAuth();
return {
signInWithMicrosoft: () => signInWithMicrosoft(workspaceInviteHash),
signInWithMicrosoft: () =>
signInWithMicrosoft({
workspaceInviteHash,
workspacePersonalInviteToken,
}),
};
};
@@ -7,6 +7,7 @@ import { AppPath } from '@/types/AppPath';
import { SnackBarVariant } from '@/ui/feedback/snack-bar-manager/components/SnackBar';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { isDefaultLayoutAuthModalVisibleState } from '@/ui/layout/states/isDefaultLayoutAuthModalVisibleState';
import { useGetWorkspaceFromInviteHashQuery } from '~/generated/graphql';
import { isDefined } from '~/utils/isDefined';
@@ -15,7 +15,7 @@ export const useObjectMetadataItem = ({
}: ObjectMetadataItemIdentifier) => {
const currentWorkspace = useRecoilValue(currentWorkspaceState);
// Todo: deprecate this logic as mocked objectMetadataItems are laod in ObjectMetadataItemsLoadEffect anyway
// Todo: deprecate this logic as mocked objectMetadataItems are load in ObjectMetadataItemsLoadEffect anyway
const mockObjectMetadataItems = getObjectMetadataItemsMock();
let objectMetadataItem = useRecoilValue(
@@ -9,12 +9,13 @@ const StyledTableRow = styled('div', {
isSelected?: boolean;
onClick?: () => void;
to?: string;
gridAutoColumns?: string;
}>`
background-color: ${({ isSelected, theme }) =>
isSelected ? theme.accent.quaternary : 'transparent'};
border-radius: ${({ theme }) => theme.border.radius.sm};
display: grid;
grid-auto-columns: 1fr;
grid-auto-columns: ${({ gridAutoColumns }) => gridAutoColumns ?? '1fr'};
grid-auto-flow: column;
transition: background-color
${({ theme }) => theme.animation.duration.normal}s;
@@ -33,6 +34,7 @@ type TableRowProps = {
onClick?: () => void;
to?: string;
className?: string;
gridAutoColumns?: string;
};
export const TableRow = ({
@@ -41,10 +43,12 @@ export const TableRow = ({
to,
className,
children,
gridAutoColumns,
}: React.PropsWithChildren<TableRowProps>) => (
<StyledTableRow
isSelected={isSelected}
onClick={onClick}
gridAutoColumns={gridAutoColumns}
className={className}
to={to}
as={to ? Link : 'div'}
@@ -0,0 +1,7 @@
import { gql } from '@apollo/client';
export const DELETE_WORKSPACE_INVITATION = gql`
mutation DeleteWorkspaceInvitation($appTokenId: String!) {
deleteWorkspaceInvitation(appTokenId: $appTokenId)
}
`;
@@ -0,0 +1,17 @@
import { gql } from '@apollo/client';
export const RESEND_WORKSPACE_INVITATION = gql`
mutation ResendWorkspaceInvitation($appTokenId: String!) {
resendWorkspaceInvitation(appTokenId: $appTokenId) {
success
errors
result {
... on WorkspaceInvitation {
id
email
expiresAt
}
}
}
}
`;
@@ -0,0 +1,17 @@
import { gql } from '@apollo/client';
export const SEND_INVITATIONS = gql`
mutation SendInvitations($emails: [String!]!) {
sendInvitations(emails: $emails) {
success
errors
result {
... on WorkspaceInvitation {
id
email
expiresAt
}
}
}
}
`;
@@ -0,0 +1,11 @@
import { gql } from '@apollo/client';
export const GET_WORKSPACE_INVITATIONS = gql`
query GetWorkspaceInvitations {
findWorkspaceInvitations {
id
email
expiresAt
}
}
`;
@@ -0,0 +1,26 @@
import { useSetRecoilState } from 'recoil';
import { useSendInvitationsMutation } from '~/generated/graphql';
import { SendInvitationsMutationVariables } from '../../../generated/graphql';
import { workspaceInvitationsState } from '../states/workspaceInvitationsStates';
export const useCreateWorkspaceInvitation = () => {
const [sendInvitationsMutation] = useSendInvitationsMutation();
const setWorkspaceInvitations = useSetRecoilState(workspaceInvitationsState);
const sendInvitation = async (emails: SendInvitationsMutationVariables) => {
return await sendInvitationsMutation({
variables: emails,
onCompleted: (data) => {
setWorkspaceInvitations((workspaceInvitations) => [
...workspaceInvitations,
...data.sendInvitations.result,
]);
},
});
};
return {
sendInvitation,
};
};
@@ -0,0 +1,34 @@
import { useSetRecoilState } from 'recoil';
import {
DeleteWorkspaceInvitationMutationVariables,
useDeleteWorkspaceInvitationMutation,
} from '~/generated/graphql';
import { workspaceInvitationsState } from '../states/workspaceInvitationsStates';
export const useDeleteWorkspaceInvitation = () => {
const [deleteWorkspaceInvitationMutation] =
useDeleteWorkspaceInvitationMutation();
const setWorkspaceInvitations = useSetRecoilState(workspaceInvitationsState);
const deleteWorkspaceInvitation = async ({
appTokenId,
}: DeleteWorkspaceInvitationMutationVariables) => {
return await deleteWorkspaceInvitationMutation({
variables: {
appTokenId,
},
onCompleted: () => {
setWorkspaceInvitations((workspaceInvitations) =>
workspaceInvitations.filter(
(workspaceInvitation) => workspaceInvitation.id !== appTokenId,
),
);
},
});
};
return {
deleteWorkspaceInvitation,
};
};
@@ -0,0 +1,35 @@
import { useSetRecoilState } from 'recoil';
import {
ResendWorkspaceInvitationMutationVariables,
useResendWorkspaceInvitationMutation,
} from '~/generated/graphql';
import { workspaceInvitationsState } from '../states/workspaceInvitationsStates';
export const useResendWorkspaceInvitation = () => {
const [resendWorkspaceInvitationMutation] =
useResendWorkspaceInvitationMutation();
const setWorkspaceInvitations = useSetRecoilState(workspaceInvitationsState);
const resendInvitation = async ({
appTokenId,
}: ResendWorkspaceInvitationMutationVariables) => {
return await resendWorkspaceInvitationMutation({
variables: {
appTokenId,
},
onCompleted: (data) => {
setWorkspaceInvitations((workspaceInvitations) => [
...data.resendWorkspaceInvitation.result,
...workspaceInvitations.filter(
(workspaceInvitation) => workspaceInvitation.id !== appTokenId,
),
]);
},
});
};
return {
resendInvitation,
};
};
@@ -0,0 +1,9 @@
import { createState } from 'twenty-ui';
import { WorkspaceInvitation } from '@/workspace-member/types/WorkspaceMember';
export const workspaceInvitationsState = createState<
Omit<WorkspaceInvitation, '__typename'>[]
>({
key: 'workspaceInvitationsState',
defaultValue: [],
});
@@ -0,0 +1,9 @@
import { gql } from '@apollo/client';
export const ADD_USER_TO_WORKSPACE_BY_INVITE_TOKEN = gql`
mutation AddUserToWorkspaceByInviteToken($inviteToken: String!) {
addUserToWorkspaceByInviteToken(inviteToken: $inviteToken) {
id
}
}
`;
@@ -24,3 +24,10 @@ export type WorkspaceMember = {
dateFormat?: WorkspaceMemberDateFormatEnum | null;
timeFormat?: WorkspaceMemberTimeFormatEnum | null;
};
export type WorkspaceInvitation = {
__typename: 'WorkspaceInvitation';
id: string;
email: string;
expiresAt: string;
};
@@ -1,9 +1,9 @@
import { useEffect } from 'react';
import { Controller, useForm } from 'react-hook-form';
import styled from '@emotion/styled';
import { zodResolver } from '@hookform/resolvers/zod';
import { useEffect } from 'react';
import { Controller, useForm } from 'react-hook-form';
import { Key } from 'ts-key-enum';
import { IconMail, IconSend } from 'twenty-ui';
import { IconSend } from 'twenty-ui';
import { z } from 'zod';
import { SnackBarVariant } from '@/ui/feedback/snack-bar-manager/components/SnackBar';
@@ -11,12 +11,13 @@ 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 { sanitizeEmailList } from '@/workspace/utils/sanitizeEmailList';
import { useSendInviteLinkMutation } from '~/generated/graphql';
import { isDefined } from '~/utils/isDefined';
import { useCreateWorkspaceInvitation } from '../../workspace-invitation/hooks/useCreateWorkspaceInvitation';
const StyledContainer = styled.div`
display: flex;
flex-direction: row;
padding-bottom: ${({ theme }) => theme.spacing(3)};
`;
const StyledLinkContainer = styled.div`
@@ -69,7 +70,7 @@ type FormInput = {
export const WorkspaceInviteTeam = () => {
const { enqueueSnackBar } = useSnackBar();
const [sendInviteLink] = useSendInviteLinkMutation();
const { sendInvitation } = useCreateWorkspaceInvitation();
const { reset, handleSubmit, control, formState } = useForm<FormInput>({
mode: 'onSubmit',
@@ -79,16 +80,27 @@ export const WorkspaceInviteTeam = () => {
},
});
const submit = handleSubmit(async (data) => {
const emailsList = sanitizeEmailList(data.emails.split(','));
const result = await sendInviteLink({ variables: { emails: emailsList } });
if (isDefined(result.errors)) {
throw result.errors;
const submit = handleSubmit(async ({ emails }) => {
const emailsList = sanitizeEmailList(emails.split(','));
const { data } = await sendInvitation({ emails: emailsList });
if (isDefined(data) && data.sendInvitations.result.length > 0) {
enqueueSnackBar(
`${data.sendInvitations.result.length} invitations sent`,
{
variant: SnackBarVariant.Success,
duration: 2000,
},
);
return;
}
if (isDefined(data) && !data.sendInvitations.success) {
data.sendInvitations.errors.forEach((error) => {
enqueueSnackBar(error, {
variant: SnackBarVariant.Error,
duration: 5000,
});
});
}
enqueueSnackBar('Invite link sent to email addresses', {
variant: SnackBarVariant.Success,
duration: 2000,
});
});
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
@@ -116,7 +128,6 @@ export const WorkspaceInviteTeam = () => {
return (
<TextInput
placeholder="tim@apple.com, jony.ive@apple.dev"
LeftIcon={IconMail}
value={value}
onChange={onChange}
error={error?.message}
@@ -1,57 +0,0 @@
import styled from '@emotion/styled';
import { Avatar, OverflowingTextWithTooltip } from 'twenty-ui';
import { WorkspaceMember } from '@/workspace-member/types/WorkspaceMember';
const StyledContainer = styled.div`
background: ${({ theme }) => theme.background.secondary};
border: 1px solid ${({ theme }) => theme.border.color.medium};
border-radius: ${({ theme }) => theme.spacing(2)};
display: flex;
flex-direction: row;
margin-bottom: ${({ theme }) => theme.spacing(0)};
margin-top: ${({ theme }) => theme.spacing(4)};
padding: ${({ theme }) => theme.spacing(3)};
`;
const StyledContent = styled.div`
display: flex;
flex: 1;
flex-direction: column;
justify-content: center;
margin-left: ${({ theme }) => theme.spacing(3)};
overflow: auto;
`;
const StyledEmailText = styled.span`
color: ${({ theme }) => theme.font.color.tertiary};
`;
type WorkspaceMemberCardProps = {
workspaceMember: WorkspaceMember;
accessory?: React.ReactNode;
};
export const WorkspaceMemberCard = ({
workspaceMember,
accessory,
}: WorkspaceMemberCardProps) => (
<StyledContainer>
<Avatar
avatarUrl={workspaceMember.avatarUrl}
placeholderColorSeed={workspaceMember.id}
placeholder={workspaceMember.name.firstName || ''}
type="squared"
size="xl"
/>
<StyledContent>
<OverflowingTextWithTooltip
text={
workspaceMember.name.firstName + ' ' + workspaceMember.name.lastName
}
/>
<StyledEmailText>{workspaceMember.userEmail}</StyledEmailText>
</StyledContent>
{accessory}
</StyledContainer>
);
@@ -1,9 +0,0 @@
import { gql } from '@apollo/client';
export const SEND_INVITE_LINK = gql`
mutation SendInviteLink($emails: [String!]!) {
sendInviteLink(emails: $emails) {
success
}
}
`;