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
parent ad18c44f25
commit 89c97993e3
81 changed files with 1726 additions and 363 deletions

View File

@ -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 {

View File

@ -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,

View File

@ -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,
],

View File

@ -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 }),
};
};

View File

@ -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,
}),
};
};

View File

@ -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';

View File

@ -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(

View File

@ -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'}

View File

@ -0,0 +1,7 @@
import { gql } from '@apollo/client';
export const DELETE_WORKSPACE_INVITATION = gql`
mutation DeleteWorkspaceInvitation($appTokenId: String!) {
deleteWorkspaceInvitation(appTokenId: $appTokenId)
}
`;

View File

@ -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
}
}
}
}
`;

View File

@ -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
}
}
}
}
`;

View File

@ -0,0 +1,11 @@
import { gql } from '@apollo/client';
export const GET_WORKSPACE_INVITATIONS = gql`
query GetWorkspaceInvitations {
findWorkspaceInvitations {
id
email
expiresAt
}
}
`;

View File

@ -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,
};
};

View File

@ -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,
};
};

View File

@ -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,
};
};

View File

@ -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: [],
});

View File

@ -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
}
}
`;

View File

@ -24,3 +24,10 @@ export type WorkspaceMember = {
dateFormat?: WorkspaceMemberDateFormatEnum | null;
timeFormat?: WorkspaceMemberTimeFormatEnum | null;
};
export type WorkspaceInvitation = {
__typename: 'WorkspaceInvitation';
id: string;
email: string;
expiresAt: string;
};

View File

@ -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}

View File

@ -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>
);

View File

@ -1,9 +0,0 @@
import { gql } from '@apollo/client';
export const SEND_INVITE_LINK = gql`
mutation SendInviteLink($emails: [String!]!) {
sendInviteLink(emails: $emails) {
success
}
}
`;