import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session';
import { useOptionalSession } from '@documenso/lib/client-only/providers/session';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { prisma } from '@documenso/prisma';
import { trpc } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { Trans, useLingui } from '@lingui/react/macro';
import { OrganisationMemberInviteStatus } from '@prisma/client';
import { useState } from 'react';
import { Link, useSearchParams } from 'react-router';
import { match } from 'ts-pattern';
import type { Route } from './+types/organisation.invite.$token';
export async function loader({ params, request }: Route.LoaderArgs) {
const session = await getOptionalSession(request);
const { token } = params;
if (!token) {
return {
state: 'InvalidLink',
} as const;
}
const organisationMemberInvite = await prisma.organisationMemberInvite.findUnique({
where: {
token,
},
include: {
organisation: {
select: {
name: true,
},
},
},
});
if (!organisationMemberInvite) {
return {
state: 'InvalidLink',
} as const;
}
const organisationName = organisationMemberInvite.organisation.name;
if (organisationMemberInvite.status === OrganisationMemberInviteStatus.ACCEPTED) {
return {
state: 'AlreadyAccepted',
organisationName,
} as const;
}
if (organisationMemberInvite.status === OrganisationMemberInviteStatus.DECLINED) {
return {
state: 'AlreadyDeclined',
organisationName,
} as const;
}
const user = await prisma.user.findFirst({
where: {
email: {
equals: organisationMemberInvite.email,
mode: 'insensitive',
},
},
select: {
id: true,
},
});
return {
state: 'Pending',
token: organisationMemberInvite.token,
email: organisationMemberInvite.email,
organisationName,
userExists: user !== null,
isSessionUserTheInvitedUser: user !== null && user.id === session.user?.id,
} as const;
}
export default function AcceptInvitationPage({ loaderData }: Route.ComponentProps) {
const data = loaderData;
if (data.state === 'InvalidLink') {
return (
Invalid token
This token is invalid or has expired. Please contact your team for a new invitation.
Return
);
}
if (data.state === 'AlreadyAccepted') {
return (
Invitation already accepted
You are already a member of {data.organisationName} .
Continue
);
}
if (data.state === 'AlreadyDeclined') {
return ;
}
return (
);
}
type PendingInvitationProps = {
token: string;
email: string;
organisationName: string;
userExists: boolean;
isSessionUserTheInvitedUser: boolean;
};
type InvitationResult = 'idle' | 'accepted' | 'declined';
type AcceptFailureReason = 'CapExceeded' | 'SubscriptionInactive' | 'Unknown';
const PendingInvitation = ({
token,
email,
organisationName,
userExists,
isSessionUserTheInvitedUser,
}: PendingInvitationProps) => {
const { t } = useLingui();
const { toast } = useToast();
const { refreshSession } = useOptionalSession();
const [searchParams] = useSearchParams();
const actionIsDecline = searchParams.get('action') === 'decline';
const [result, setResult] = useState('idle');
const [acceptFailureReason, setAcceptFailureReason] = useState(null);
const acceptInvitation = trpc.organisation.member.invite.accept.useMutation({
onSuccess: async () => {
await refreshSession();
setResult('accepted');
},
onError: (err) => {
const error = AppError.parseError(err);
const failureReason = match(error.code)
.with(AppErrorCode.LIMIT_EXCEEDED, () => 'CapExceeded' as const)
.with('SUBSCRIPTION_INACTIVE', () => 'SubscriptionInactive' as const)
.otherwise(() => 'Unknown' as const);
setAcceptFailureReason(failureReason);
},
});
const declineInvitation = trpc.organisation.member.invite.decline.useMutation({
onSuccess: async () => {
await refreshSession();
setResult('declined');
},
onError: () => {
toast({
title: t`Something went wrong`,
description: t`Unable to decline this invitation at this time.`,
variant: 'destructive',
duration: 10000,
});
},
});
if (result === 'accepted') {
return (
Invitation accepted!
You have accepted an invitation from {organisationName} to join their organisation.
{isSessionUserTheInvitedUser ? (
Continue
) : (
Continue to login
)}
);
}
if (result === 'declined') {
return ;
}
// Accepting requires an account (acceptance keys off the invited email).
// Declining does not, so we only gate account creation on the accept flow.
if (!actionIsDecline && !userExists) {
return (
Organisation invitation
You have been invited by {organisationName} to join their organisation.
To accept this invitation you must create an account.
Create account
);
}
const isPending = acceptInvitation.isPending || declineInvitation.isPending;
return (
Organisation invitation
You have been invited to join {organisationName} on Documenso.
{acceptFailureReason && (
{match(acceptFailureReason)
.with('CapExceeded', () => (
{organisationName} has reached its member limit. Please contact the organisation
administrator to upgrade their plan before accepting this invitation.
))
.with('SubscriptionInactive', () => (
{organisationName} does not have an active subscription. Please contact the
organisation administrator to renew their plan before accepting this invitation.
))
.with('Unknown', () => (
We were unable to add you to {organisationName} at this time. Please try again later,
or contact the organisation administrator.
))
.exhaustive()}
)}
declineInvitation.mutateAsync({ token })}
loading={declineInvitation.isPending}
disabled={isPending}
>
Decline
{!actionIsDecline && (
acceptInvitation.mutateAsync({ token })}
loading={acceptInvitation.isPending}
disabled={isPending}
>
Accept
)}
);
};
const InvitationDeclined = ({ organisationName }: { organisationName: string }) => {
return (
Invitation declined
You have declined the invitation from {organisationName} to join their organisation.
);
};