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

@ -13,8 +13,12 @@ import { Loader } from '@/ui/feedback/loader/components/Loader';
import { MainButton } from '@/ui/input/button/components/MainButton';
import { useWorkspaceSwitching } from '@/ui/navigation/navigation-drawer/hooks/useWorkspaceSwitching';
import { AnimatedEaseIn } from '@/ui/utilities/animation/components/AnimatedEaseIn';
import { useAddUserToWorkspaceMutation } from '~/generated/graphql';
import {
useAddUserToWorkspaceMutation,
useAddUserToWorkspaceByInviteTokenMutation,
} from '~/generated/graphql';
import { isDefined } from '~/utils/isDefined';
import { useSearchParams } from 'react-router-dom';
const StyledContentContainer = styled.div`
margin-bottom: ${({ theme }) => theme.spacing(8)};
@ -24,26 +28,40 @@ const StyledContentContainer = styled.div`
export const Invite = () => {
const { workspace: workspaceFromInviteHash, workspaceInviteHash } =
useWorkspaceFromInviteHash();
const { form } = useSignInUpForm();
const currentWorkspace = useRecoilValue(currentWorkspaceState);
const [addUserToWorkspace] = useAddUserToWorkspaceMutation();
const [addUserToWorkspaceByInviteToken] =
useAddUserToWorkspaceByInviteTokenMutation();
const { switchWorkspace } = useWorkspaceSwitching();
const [searchParams] = useSearchParams();
const workspaceInviteToken = searchParams.get('inviteToken');
const title = useMemo(() => {
return `Join ${workspaceFromInviteHash?.displayName ?? ''} team`;
}, [workspaceFromInviteHash?.displayName]);
const handleUserJoinWorkspace = async () => {
if (
!(isDefined(workspaceInviteHash) && isDefined(workspaceFromInviteHash))
if (isDefined(workspaceInviteToken) && isDefined(workspaceFromInviteHash)) {
await addUserToWorkspaceByInviteToken({
variables: {
inviteToken: workspaceInviteToken,
},
});
} else if (
isDefined(workspaceInviteHash) &&
isDefined(workspaceFromInviteHash)
) {
await addUserToWorkspace({
variables: {
inviteHash: workspaceInviteHash,
},
});
} else {
return;
}
await addUserToWorkspace({
variables: {
inviteHash: workspaceInviteHash,
},
});
await switchWorkspace(workspaceFromInviteHash.id);
};

View File

@ -27,11 +27,9 @@ import { MainButton } from '@/ui/input/button/components/MainButton';
import { TextInputV2 } from '@/ui/input/components/TextInputV2';
import { AnimatedTranslation } from '@/ui/utilities/animation/components/AnimatedTranslation';
import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys';
import {
OnboardingStatus,
useSendInviteLinkMutation,
} from '~/generated/graphql';
import { OnboardingStatus } from '~/generated/graphql';
import { isDefined } from '~/utils/isDefined';
import { useCreateWorkspaceInvitation } from '../../modules/workspace-invitation/hooks/useCreateWorkspaceInvitation';
const StyledAnimatedContainer = styled.div`
display: flex;
@ -65,7 +63,8 @@ type FormInput = z.infer<typeof validationSchema>;
export const InviteTeam = () => {
const theme = useTheme();
const { enqueueSnackBar } = useSnackBar();
const [sendInviteLink] = useSendInviteLinkMutation();
const { sendInvitation } = useCreateWorkspaceInvitation();
const setNextOnboardingStatus = useSetNextOnboardingStatus();
const currentUser = useRecoilValue(currentUserState);
const currentWorkspace = useRecoilValue(currentWorkspaceState);
@ -134,7 +133,7 @@ export const InviteTeam = () => {
.filter((email) => email.length > 0),
),
);
const result = await sendInviteLink({ variables: { emails } });
const result = await sendInvitation({ emails });
setNextOnboardingStatus();
@ -148,7 +147,7 @@ export const InviteTeam = () => {
});
}
},
[enqueueSnackBar, sendInviteLink, setNextOnboardingStatus],
[enqueueSnackBar, sendInvitation, setNextOnboardingStatus],
);
useScopedHotkeys(

View File

@ -1,7 +1,17 @@
import styled from '@emotion/styled';
import { useState } from 'react';
import { useRecoilValue } from 'recoil';
import { H2Title, IconTrash, IconUsers } from 'twenty-ui';
import { useRecoilValue, useSetRecoilState } from 'recoil';
import {
H2Title,
IconTrash,
IconUsers,
IconReload,
IconMail,
StyledText,
Avatar,
} from 'twenty-ui';
import { isNonEmptyArray } from '@sniptt/guards';
import { useTheme } from '@emotion/react';
import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState';
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
@ -18,7 +28,19 @@ import { Section } from '@/ui/layout/section/components/Section';
import { WorkspaceMember } from '@/workspace-member/types/WorkspaceMember';
import { WorkspaceInviteLink } from '@/workspace/components/WorkspaceInviteLink';
import { WorkspaceInviteTeam } from '@/workspace/components/WorkspaceInviteTeam';
import { WorkspaceMemberCard } from '@/workspace/components/WorkspaceMemberCard';
import { useGetWorkspaceInvitationsQuery } from '~/generated/graphql';
import { SnackBarVariant } from '@/ui/feedback/snack-bar-manager/components/SnackBar';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { Table } from '@/ui/layout/table/components/Table';
import { TableHeader } from '@/ui/layout/table/components/TableHeader';
import { workspaceInvitationsState } from '../../modules/workspace-invitation/states/workspaceInvitationsStates';
import { TableRow } from '../../modules/ui/layout/table/components/TableRow';
import { TableCell } from '../../modules/ui/layout/table/components/TableCell';
import { Status } from '../../modules/ui/display/status/components/Status';
import { formatDistanceToNow } from 'date-fns';
import { useResendWorkspaceInvitation } from '../../modules/workspace-invitation/hooks/useResendWorkspaceInvitation';
import { isDefined } from '~/utils/isDefined';
import { useDeleteWorkspaceInvitation } from '../../modules/workspace-invitation/hooks/useDeleteWorkspaceInvitation';
const StyledButtonContainer = styled.div`
align-items: center;
@ -27,7 +49,17 @@ const StyledButtonContainer = styled.div`
margin-left: ${({ theme }) => theme.spacing(3)};
`;
const StyledTable = styled(Table)`
margin-top: ${({ theme }) => theme.spacing(0.5)};
`;
const StyledTableHeaderRow = styled(Table)`
margin-bottom: ${({ theme }) => theme.spacing(1.5)};
`;
export const SettingsWorkspaceMembers = () => {
const { enqueueSnackBar } = useSnackBar();
const theme = useTheme();
const [isConfirmationModalOpen, setIsConfirmationModalOpen] = useState(false);
const [workspaceMemberToDelete, setWorkspaceMemberToDelete] = useState<
string | undefined
@ -39,6 +71,10 @@ export const SettingsWorkspaceMembers = () => {
const { deleteOneRecord: deleteOneWorkspaceMember } = useDeleteOneRecord({
objectNameSingular: CoreObjectNameSingular.WorkspaceMember,
});
const { resendInvitation } = useResendWorkspaceInvitation();
const { deleteWorkspaceInvitation } = useDeleteWorkspaceInvitation();
const currentWorkspace = useRecoilValue(currentWorkspaceState);
const currentWorkspaceMember = useRecoilValue(currentWorkspaceMemberState);
@ -47,6 +83,47 @@ export const SettingsWorkspaceMembers = () => {
setIsConfirmationModalOpen(false);
};
const workspaceInvitations = useRecoilValue(workspaceInvitationsState);
const setWorkspaceInvitations = useSetRecoilState(workspaceInvitationsState);
useGetWorkspaceInvitationsQuery({
onError: (error: Error) => {
enqueueSnackBar(error.message, {
variant: SnackBarVariant.Error,
});
},
onCompleted: (data) => {
setWorkspaceInvitations(data?.findWorkspaceInvitations ?? []);
},
});
const handleRemoveWorkspaceInvitation = async (appTokenId: string) => {
const result = await deleteWorkspaceInvitation({ appTokenId });
if (isDefined(result.errors)) {
enqueueSnackBar('Error deleting invitation', {
variant: SnackBarVariant.Error,
duration: 2000,
});
}
};
const handleResendWorkspaceInvitation = async (appTokenId: string) => {
const result = await resendInvitation({ appTokenId });
if (isDefined(result.errors)) {
enqueueSnackBar('Error resending invitation', {
variant: SnackBarVariant.Error,
duration: 2000,
});
}
};
const getExpiresAtText = (expiresAt: string) => {
const expiresAtDate = new Date(expiresAt);
return expiresAtDate < new Date()
? 'Expired'
: formatDistanceToNow(new Date(expiresAt));
};
return (
<SubMenuTopBarContainer
Icon={IconUsers}
@ -60,18 +137,11 @@ export const SettingsWorkspaceMembers = () => {
]}
>
<SettingsPageContainer>
<Section>
<H2Title
title="Invite by email"
description="Send an invite email to your team"
/>
<WorkspaceInviteTeam />
</Section>
{currentWorkspace?.inviteHash && (
<Section>
<H2Title
title="Or send an invite link"
description="Copy and send an invite link directly"
title="Invite by link"
description="Share this link to invite users to join your workspace"
/>
<WorkspaceInviteLink
inviteLink={`${window.location.origin}/invite/${currentWorkspace?.inviteHash}`}
@ -83,27 +153,125 @@ export const SettingsWorkspaceMembers = () => {
title="Members"
description="Manage the members of your space here"
/>
{workspaceMembers?.map((member) => (
<WorkspaceMemberCard
key={member.id}
workspaceMember={member as WorkspaceMember}
accessory={
currentWorkspaceMember?.id !== member.id && (
<StyledButtonContainer>
<IconButton
onClick={() => {
setIsConfirmationModalOpen(true);
setWorkspaceMemberToDelete(member.id);
}}
variant="tertiary"
size="medium"
Icon={IconTrash}
<Table>
<StyledTableHeaderRow>
<TableRow>
<TableHeader>Name</TableHeader>
<TableHeader>Email</TableHeader>
<TableHeader align={'right'}></TableHeader>
</TableRow>
</StyledTableHeaderRow>
{workspaceMembers?.map((workspaceMember) => (
<StyledTable key={workspaceMember.id}>
<TableRow>
<TableCell>
<StyledText
PrefixComponent={
<Avatar
avatarUrl={workspaceMember.avatarUrl}
placeholderColorSeed={workspaceMember.id}
placeholder={workspaceMember.name.firstName ?? ''}
type="rounded"
size="sm"
/>
}
text={
workspaceMember.name.firstName +
' ' +
workspaceMember.name.lastName
}
/>
</StyledButtonContainer>
)
}
/>
))}
</TableCell>
<TableCell>
<StyledText
text={workspaceMember.userEmail}
color={theme.font.color.secondary}
/>
</TableCell>
<TableCell align={'right'}>
{currentWorkspaceMember?.id !== workspaceMember.id && (
<StyledButtonContainer>
<IconButton
onClick={() => {
setIsConfirmationModalOpen(true);
setWorkspaceMemberToDelete(workspaceMember.id);
}}
variant="tertiary"
size="medium"
Icon={IconTrash}
/>
</StyledButtonContainer>
)}
</TableCell>
</TableRow>
</StyledTable>
))}
</Table>
</Section>
<Section>
<H2Title
title="Invite by email"
description="Send an invite email to your team"
/>
<WorkspaceInviteTeam />
{isNonEmptyArray(workspaceInvitations) && (
<Table>
<StyledTableHeaderRow>
<TableRow gridAutoColumns={`1fr 1fr ${theme.spacing(22)}`}>
<TableHeader>Email</TableHeader>
<TableHeader align={'right'}>Expires in</TableHeader>
<TableHeader></TableHeader>
</TableRow>
</StyledTableHeaderRow>
{workspaceInvitations?.map((workspaceInvitation) => (
<StyledTable key={workspaceInvitation.id}>
<TableRow gridAutoColumns={`1fr 1fr ${theme.spacing(22)}`}>
<TableCell>
<StyledText
PrefixComponent={
<IconMail
size={theme.icon.size.md}
stroke={theme.icon.stroke.sm}
/>
}
text={workspaceInvitation.email}
/>
</TableCell>
<TableCell align={'right'}>
<Status
color={'gray'}
text={getExpiresAtText(workspaceInvitation.expiresAt)}
/>
</TableCell>
<TableCell align={'right'}>
<StyledButtonContainer>
<IconButton
onClick={() => {
handleResendWorkspaceInvitation(
workspaceInvitation.id,
);
}}
variant="tertiary"
size="medium"
Icon={IconReload}
/>
<IconButton
onClick={() => {
handleRemoveWorkspaceInvitation(
workspaceInvitation.id,
);
}}
variant="tertiary"
size="medium"
Icon={IconTrash}
/>
</StyledButtonContainer>
</TableCell>
</TableRow>
</StyledTable>
))}
</Table>
)}
</Section>
</SettingsPageContainer>
<ConfirmationModal