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