mirror of
https://github.com/documenso/documenso.git
synced 2025-11-18 02:32:00 +10:00
feat: add envelopes api (#2105)
This commit is contained in:
@ -7,6 +7,8 @@ import {
|
||||
} from '@documenso/lib/constants/auth';
|
||||
|
||||
import { EmbedAuthenticationRequired } from '~/components/embed/embed-authentication-required';
|
||||
import { EmbedDocumentCompleted } from '~/components/embed/embed-document-completed';
|
||||
import { EmbedDocumentRejected } from '~/components/embed/embed-document-rejected';
|
||||
import { EmbedDocumentWaitingForTurn } from '~/components/embed/embed-document-waiting-for-turn';
|
||||
import { EmbedPaywall } from '~/components/embed/embed-paywall';
|
||||
|
||||
@ -48,6 +50,8 @@ export function ErrorBoundary({ loaderData }: Route.ErrorBoundaryProps) {
|
||||
|
||||
const error = useRouteError();
|
||||
|
||||
console.log({ routeError: error });
|
||||
|
||||
if (isRouteErrorResponse(error)) {
|
||||
if (error.status === 401 && error.data.type === 'embed-authentication-required') {
|
||||
return (
|
||||
@ -68,6 +72,16 @@ export function ErrorBoundary({ loaderData }: Route.ErrorBoundaryProps) {
|
||||
if (error.status === 403 && error.data.type === 'embed-waiting-for-turn') {
|
||||
return <EmbedDocumentWaitingForTurn />;
|
||||
}
|
||||
|
||||
// !: Not used at the moment, may be removed in the future.
|
||||
if (error.status === 403 && error.data.type === 'embed-document-rejected') {
|
||||
return <EmbedDocumentRejected />;
|
||||
}
|
||||
|
||||
// !: Not used at the moment, may be removed in the future.
|
||||
if (error.status === 403 && error.data.type === 'embed-document-completed') {
|
||||
return <EmbedDocumentCompleted name={error.data.name} signature={error.data.signature} />;
|
||||
}
|
||||
}
|
||||
|
||||
return <div>Not Found</div>;
|
||||
|
||||
332
apps/remix/app/routes/embed+/_v0+/direct.$token.tsx
Normal file
332
apps/remix/app/routes/embed+/_v0+/direct.$token.tsx
Normal file
@ -0,0 +1,332 @@
|
||||
import { data } from 'react-router';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session';
|
||||
import { EnvelopeRenderProvider } from '@documenso/lib/client-only/providers/envelope-render-provider';
|
||||
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { getEnvelopeForDirectTemplateSigning } from '@documenso/lib/server-only/envelope/get-envelope-for-direct-template-signing';
|
||||
import { getEnvelopeRequiredAccessData } from '@documenso/lib/server-only/envelope/get-envelope-required-access-data';
|
||||
import { getOrganisationClaimByTeamId } from '@documenso/lib/server-only/organisation/get-organisation-claims';
|
||||
import { getTemplateByDirectLinkToken } from '@documenso/lib/server-only/template/get-template-by-direct-link-token';
|
||||
import { DocumentAccessAuth } from '@documenso/lib/types/document-auth';
|
||||
import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { EmbedDirectTemplateClientPage } from '~/components/embed/embed-direct-template-client-page';
|
||||
import { EmbedSignDocumentV2ClientPage } from '~/components/embed/embed-document-signing-page-v2';
|
||||
import { DocumentSigningAuthProvider } from '~/components/general/document-signing/document-signing-auth-provider';
|
||||
import { DocumentSigningProvider } from '~/components/general/document-signing/document-signing-provider';
|
||||
import { DocumentSigningRecipientProvider } from '~/components/general/document-signing/document-signing-recipient-provider';
|
||||
import { EnvelopeSigningProvider } from '~/components/general/document-signing/envelope-signing-provider';
|
||||
import { superLoaderJson, useSuperLoaderData } from '~/utils/super-json-loader';
|
||||
|
||||
import type { Route } from './+types/direct.$token';
|
||||
|
||||
async function handleV1Loader({ params, request }: Route.LoaderArgs) {
|
||||
if (!params.token) {
|
||||
throw new Response('Not found', { status: 404 });
|
||||
}
|
||||
|
||||
const token = params.token;
|
||||
|
||||
const template = await getTemplateByDirectLinkToken({
|
||||
token,
|
||||
}).catch(() => null);
|
||||
|
||||
// `template.directLink` is always available but we're doing this to
|
||||
// satisfy the type checker.
|
||||
if (!template || !template.directLink) {
|
||||
throw new Response('Not found', { status: 404 });
|
||||
}
|
||||
|
||||
const organisationClaim = await getOrganisationClaimByTeamId({ teamId: template.teamId });
|
||||
|
||||
const allowEmbedSigningWhitelabel = organisationClaim.flags.embedSigningWhiteLabel;
|
||||
const hidePoweredBy = organisationClaim.flags.hidePoweredBy;
|
||||
|
||||
// TODO: Make this more robust, we need to ensure the owner is either
|
||||
// TODO: the member of a team that has an active subscription, is an early
|
||||
// TODO: adopter or is an enterprise user.
|
||||
if (IS_BILLING_ENABLED() && !organisationClaim.flags.embedSigning) {
|
||||
throw data(
|
||||
{
|
||||
type: 'embed-paywall',
|
||||
},
|
||||
{
|
||||
status: 403,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const { user } = await getOptionalSession(request);
|
||||
|
||||
const { derivedRecipientAccessAuth } = extractDocumentAuthMethods({
|
||||
documentAuth: template.authOptions,
|
||||
});
|
||||
|
||||
const isAccessAuthValid = derivedRecipientAccessAuth.every((auth) =>
|
||||
match(auth)
|
||||
.with(DocumentAccessAuth.ACCOUNT, () => !!user)
|
||||
.with(DocumentAccessAuth.TWO_FACTOR_AUTH, () => false) // Not supported for direct links
|
||||
.exhaustive(),
|
||||
);
|
||||
|
||||
if (!isAccessAuthValid) {
|
||||
throw data(
|
||||
{
|
||||
type: 'embed-authentication-required',
|
||||
email: user?.email,
|
||||
returnTo: `/embed/direct/${token}`,
|
||||
},
|
||||
{
|
||||
status: 401,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const { directTemplateRecipientId } = template.directLink;
|
||||
|
||||
const recipient = template.recipients.find(
|
||||
(recipient) => recipient.id === directTemplateRecipientId,
|
||||
);
|
||||
|
||||
if (!recipient) {
|
||||
throw new Response('Not found', { status: 404 });
|
||||
}
|
||||
|
||||
const fields = template.fields.filter((field) => field.recipientId === directTemplateRecipientId);
|
||||
|
||||
return {
|
||||
token,
|
||||
user,
|
||||
template,
|
||||
recipient,
|
||||
fields,
|
||||
hidePoweredBy,
|
||||
allowEmbedSigningWhitelabel,
|
||||
};
|
||||
}
|
||||
|
||||
async function handleV2Loader({ params, request }: Route.LoaderArgs) {
|
||||
if (!params.token) {
|
||||
throw new Response('Not found', { status: 404 });
|
||||
}
|
||||
|
||||
const token = params.token;
|
||||
|
||||
const { user } = await getOptionalSession(request);
|
||||
|
||||
const envelopeForSigning = await getEnvelopeForDirectTemplateSigning({
|
||||
token,
|
||||
userId: user?.id,
|
||||
})
|
||||
.then((envelopeForSigning) => {
|
||||
return {
|
||||
isDocumentAccessValid: true,
|
||||
...envelopeForSigning,
|
||||
} as const;
|
||||
})
|
||||
.catch(async (e) => {
|
||||
const error = AppError.parseError(e);
|
||||
|
||||
if (error.code === AppErrorCode.UNAUTHORIZED) {
|
||||
const requiredAccessData = await getEnvelopeRequiredAccessData({ token });
|
||||
|
||||
return {
|
||||
isDocumentAccessValid: false,
|
||||
...requiredAccessData,
|
||||
} as const;
|
||||
}
|
||||
|
||||
throw new Response('Not Found', { status: 404 });
|
||||
});
|
||||
|
||||
if (!envelopeForSigning.isDocumentAccessValid) {
|
||||
throw data(
|
||||
{
|
||||
type: 'embed-authentication-required',
|
||||
email: envelopeForSigning.recipientEmail,
|
||||
returnTo: `/embed/direct/${token}`,
|
||||
},
|
||||
{
|
||||
status: 401,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const { envelope, recipient } = envelopeForSigning;
|
||||
|
||||
const organisationClaim = await getOrganisationClaimByTeamId({ teamId: envelope.teamId });
|
||||
|
||||
const allowEmbedSigningWhitelabel = organisationClaim.flags.embedSigningWhiteLabel;
|
||||
const hidePoweredBy = organisationClaim.flags.hidePoweredBy;
|
||||
|
||||
if (IS_BILLING_ENABLED() && !organisationClaim.flags.embedSigning) {
|
||||
throw data(
|
||||
{
|
||||
type: 'embed-paywall',
|
||||
},
|
||||
{
|
||||
status: 403,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const { derivedRecipientAccessAuth } = extractDocumentAuthMethods({
|
||||
documentAuth: envelope.authOptions,
|
||||
recipientAuth: recipient.authOptions,
|
||||
});
|
||||
|
||||
const isAccessAuthValid = derivedRecipientAccessAuth.every((accesssAuth) =>
|
||||
match(accesssAuth)
|
||||
.with(DocumentAccessAuth.ACCOUNT, () => user && user.email === recipient.email)
|
||||
.with(DocumentAccessAuth.TWO_FACTOR_AUTH, () => false) // Not supported for direct links
|
||||
.exhaustive(),
|
||||
);
|
||||
|
||||
if (!isAccessAuthValid) {
|
||||
throw data(
|
||||
{
|
||||
type: 'embed-authentication-required',
|
||||
email: user?.email || recipient.email,
|
||||
returnTo: `/embed/direct/${token}`,
|
||||
},
|
||||
{
|
||||
status: 401,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
token,
|
||||
user,
|
||||
envelopeForSigning,
|
||||
hidePoweredBy,
|
||||
allowEmbedSigningWhitelabel,
|
||||
};
|
||||
}
|
||||
|
||||
export async function loader(loaderArgs: Route.LoaderArgs) {
|
||||
const { token } = loaderArgs.params;
|
||||
|
||||
if (!token) {
|
||||
throw new Response('Not Found', { status: 404 });
|
||||
}
|
||||
|
||||
// Not efficient but works for now until we remove v1.
|
||||
const foundDirectLink = await prisma.templateDirectLink.findFirst({
|
||||
where: {
|
||||
token,
|
||||
},
|
||||
select: {
|
||||
envelope: {
|
||||
select: {
|
||||
internalVersion: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!foundDirectLink) {
|
||||
throw new Response('Not Found', { status: 404 });
|
||||
}
|
||||
|
||||
if (foundDirectLink.envelope.internalVersion === 2) {
|
||||
const payloadV2 = await handleV2Loader(loaderArgs);
|
||||
|
||||
return superLoaderJson({
|
||||
version: 2,
|
||||
payload: payloadV2,
|
||||
} as const);
|
||||
}
|
||||
|
||||
const payloadV1 = await handleV1Loader(loaderArgs);
|
||||
|
||||
return superLoaderJson({
|
||||
version: 1,
|
||||
payload: payloadV1,
|
||||
} as const);
|
||||
}
|
||||
|
||||
export default function EmbedDirectTemplatePage() {
|
||||
const { version, payload } = useSuperLoaderData<typeof loader>();
|
||||
|
||||
if (version === 1) {
|
||||
return <EmbedDirectTemplatePageV1 data={payload} />;
|
||||
}
|
||||
|
||||
return <EmbedDirectTemplatePageV2 data={payload} />;
|
||||
}
|
||||
|
||||
const EmbedDirectTemplatePageV1 = ({
|
||||
data,
|
||||
}: {
|
||||
data: Awaited<ReturnType<typeof handleV1Loader>>;
|
||||
}) => {
|
||||
const { token, user, template, recipient, fields, hidePoweredBy, allowEmbedSigningWhitelabel } =
|
||||
data;
|
||||
|
||||
return (
|
||||
<DocumentSigningProvider
|
||||
email={user?.email}
|
||||
fullName={user?.name}
|
||||
signature={user?.signature}
|
||||
typedSignatureEnabled={template.templateMeta?.typedSignatureEnabled}
|
||||
uploadSignatureEnabled={template.templateMeta?.uploadSignatureEnabled}
|
||||
drawSignatureEnabled={template.templateMeta?.drawSignatureEnabled}
|
||||
>
|
||||
<DocumentSigningAuthProvider
|
||||
documentAuthOptions={template.authOptions}
|
||||
recipient={recipient}
|
||||
user={user}
|
||||
>
|
||||
<DocumentSigningRecipientProvider recipient={recipient}>
|
||||
<EmbedDirectTemplateClientPage
|
||||
token={token}
|
||||
envelopeId={template.envelopeId}
|
||||
updatedAt={template.updatedAt}
|
||||
envelopeItems={template.envelopeItems}
|
||||
recipient={recipient}
|
||||
fields={fields}
|
||||
metadata={template.templateMeta}
|
||||
hidePoweredBy={hidePoweredBy}
|
||||
allowWhiteLabelling={allowEmbedSigningWhitelabel}
|
||||
/>
|
||||
</DocumentSigningRecipientProvider>
|
||||
</DocumentSigningAuthProvider>
|
||||
</DocumentSigningProvider>
|
||||
);
|
||||
};
|
||||
|
||||
const EmbedDirectTemplatePageV2 = ({
|
||||
data,
|
||||
}: {
|
||||
data: Awaited<ReturnType<typeof handleV2Loader>>;
|
||||
}) => {
|
||||
const { token, user, envelopeForSigning, hidePoweredBy, allowEmbedSigningWhitelabel } = data;
|
||||
|
||||
const { envelope, recipient } = envelopeForSigning;
|
||||
|
||||
return (
|
||||
<EnvelopeSigningProvider
|
||||
envelopeData={envelopeForSigning}
|
||||
email={user?.email}
|
||||
fullName={user?.name}
|
||||
signature={user?.signature}
|
||||
>
|
||||
<DocumentSigningAuthProvider
|
||||
documentAuthOptions={envelope.authOptions}
|
||||
recipient={recipient}
|
||||
user={user}
|
||||
>
|
||||
<EnvelopeRenderProvider envelope={envelope} token={recipient.token}>
|
||||
<EmbedSignDocumentV2ClientPage
|
||||
hidePoweredBy={hidePoweredBy}
|
||||
allowWhitelabelling={allowEmbedSigningWhitelabel}
|
||||
/>
|
||||
</EnvelopeRenderProvider>
|
||||
</DocumentSigningAuthProvider>
|
||||
</EnvelopeSigningProvider>
|
||||
);
|
||||
};
|
||||
@ -1,138 +0,0 @@
|
||||
import { data } from 'react-router';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session';
|
||||
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
|
||||
import { getOrganisationClaimByTeamId } from '@documenso/lib/server-only/organisation/get-organisation-claims';
|
||||
import { getTemplateByDirectLinkToken } from '@documenso/lib/server-only/template/get-template-by-direct-link-token';
|
||||
import { DocumentAccessAuth } from '@documenso/lib/types/document-auth';
|
||||
import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth';
|
||||
|
||||
import { EmbedDirectTemplateClientPage } from '~/components/embed/embed-direct-template-client-page';
|
||||
import { DocumentSigningAuthProvider } from '~/components/general/document-signing/document-signing-auth-provider';
|
||||
import { DocumentSigningProvider } from '~/components/general/document-signing/document-signing-provider';
|
||||
import { DocumentSigningRecipientProvider } from '~/components/general/document-signing/document-signing-recipient-provider';
|
||||
import { superLoaderJson, useSuperLoaderData } from '~/utils/super-json-loader';
|
||||
|
||||
import type { Route } from './+types/direct.$url';
|
||||
|
||||
export async function loader({ params, request }: Route.LoaderArgs) {
|
||||
if (!params.url) {
|
||||
throw new Response('Not found', { status: 404 });
|
||||
}
|
||||
|
||||
const token = params.url;
|
||||
|
||||
const template = await getTemplateByDirectLinkToken({
|
||||
token,
|
||||
}).catch(() => null);
|
||||
|
||||
// `template.directLink` is always available but we're doing this to
|
||||
// satisfy the type checker.
|
||||
if (!template || !template.directLink) {
|
||||
throw new Response('Not found', { status: 404 });
|
||||
}
|
||||
|
||||
const organisationClaim = await getOrganisationClaimByTeamId({ teamId: template.teamId });
|
||||
|
||||
const allowEmbedSigningWhitelabel = organisationClaim.flags.embedSigningWhiteLabel;
|
||||
const hidePoweredBy = organisationClaim.flags.hidePoweredBy;
|
||||
|
||||
// TODO: Make this more robust, we need to ensure the owner is either
|
||||
// TODO: the member of a team that has an active subscription, is an early
|
||||
// TODO: adopter or is an enterprise user.
|
||||
if (IS_BILLING_ENABLED() && !organisationClaim.flags.embedSigning) {
|
||||
throw data(
|
||||
{
|
||||
type: 'embed-paywall',
|
||||
},
|
||||
{
|
||||
status: 403,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const { user } = await getOptionalSession(request);
|
||||
|
||||
const { derivedRecipientAccessAuth } = extractDocumentAuthMethods({
|
||||
documentAuth: template.authOptions,
|
||||
});
|
||||
|
||||
const isAccessAuthValid = derivedRecipientAccessAuth.every((auth) =>
|
||||
match(auth)
|
||||
.with(DocumentAccessAuth.ACCOUNT, () => !!user)
|
||||
.with(DocumentAccessAuth.TWO_FACTOR_AUTH, () => false) // Not supported for direct links
|
||||
.exhaustive(),
|
||||
);
|
||||
|
||||
if (!isAccessAuthValid) {
|
||||
throw data(
|
||||
{
|
||||
type: 'embed-authentication-required',
|
||||
email: user?.email,
|
||||
returnTo: `/embed/direct/${token}`,
|
||||
},
|
||||
{
|
||||
status: 401,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const { directTemplateRecipientId } = template.directLink;
|
||||
|
||||
const recipient = template.recipients.find(
|
||||
(recipient) => recipient.id === directTemplateRecipientId,
|
||||
);
|
||||
|
||||
if (!recipient) {
|
||||
throw new Response('Not found', { status: 404 });
|
||||
}
|
||||
|
||||
const fields = template.fields.filter((field) => field.recipientId === directTemplateRecipientId);
|
||||
|
||||
return superLoaderJson({
|
||||
token,
|
||||
user,
|
||||
template,
|
||||
recipient,
|
||||
fields,
|
||||
hidePoweredBy,
|
||||
allowEmbedSigningWhitelabel,
|
||||
});
|
||||
}
|
||||
|
||||
export default function EmbedDirectTemplatePage() {
|
||||
const { token, user, template, recipient, fields, hidePoweredBy, allowEmbedSigningWhitelabel } =
|
||||
useSuperLoaderData<typeof loader>();
|
||||
|
||||
return (
|
||||
<DocumentSigningProvider
|
||||
email={user?.email}
|
||||
fullName={user?.name}
|
||||
signature={user?.signature}
|
||||
typedSignatureEnabled={template.templateMeta?.typedSignatureEnabled}
|
||||
uploadSignatureEnabled={template.templateMeta?.uploadSignatureEnabled}
|
||||
drawSignatureEnabled={template.templateMeta?.drawSignatureEnabled}
|
||||
>
|
||||
<DocumentSigningAuthProvider
|
||||
documentAuthOptions={template.authOptions}
|
||||
recipient={recipient}
|
||||
user={user}
|
||||
>
|
||||
<DocumentSigningRecipientProvider recipient={recipient}>
|
||||
<EmbedDirectTemplateClientPage
|
||||
token={token}
|
||||
envelopeId={template.envelopeId}
|
||||
updatedAt={template.updatedAt}
|
||||
documentData={template.templateDocumentData}
|
||||
recipient={recipient}
|
||||
fields={fields}
|
||||
metadata={template.templateMeta}
|
||||
hidePoweredBy={hidePoweredBy}
|
||||
allowWhiteLabelling={allowEmbedSigningWhitelabel}
|
||||
/>
|
||||
</DocumentSigningRecipientProvider>
|
||||
</DocumentSigningAuthProvider>
|
||||
</DocumentSigningProvider>
|
||||
);
|
||||
}
|
||||
394
apps/remix/app/routes/embed+/_v0+/sign.$token.tsx
Normal file
394
apps/remix/app/routes/embed+/_v0+/sign.$token.tsx
Normal file
@ -0,0 +1,394 @@
|
||||
import { RecipientRole } from '@prisma/client';
|
||||
import { data } from 'react-router';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session';
|
||||
import { EnvelopeRenderProvider } from '@documenso/lib/client-only/providers/envelope-render-provider';
|
||||
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { getDocumentAndSenderByToken } from '@documenso/lib/server-only/document/get-document-by-token';
|
||||
import { viewedDocument } from '@documenso/lib/server-only/document/viewed-document';
|
||||
import { getEnvelopeForRecipientSigning } from '@documenso/lib/server-only/envelope/get-envelope-for-recipient-signing';
|
||||
import { getEnvelopeRequiredAccessData } from '@documenso/lib/server-only/envelope/get-envelope-required-access-data';
|
||||
import { getCompletedFieldsForToken } from '@documenso/lib/server-only/field/get-completed-fields-for-token';
|
||||
import { getFieldsForToken } from '@documenso/lib/server-only/field/get-fields-for-token';
|
||||
import { getOrganisationClaimByTeamId } from '@documenso/lib/server-only/organisation/get-organisation-claims';
|
||||
import { getIsRecipientsTurnToSign } from '@documenso/lib/server-only/recipient/get-is-recipient-turn';
|
||||
import { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-token';
|
||||
import { getRecipientsForAssistant } from '@documenso/lib/server-only/recipient/get-recipients-for-assistant';
|
||||
import { DocumentAccessAuth } from '@documenso/lib/types/document-auth';
|
||||
import { isDocumentCompleted } from '@documenso/lib/utils/document';
|
||||
import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { EmbedSignDocumentV1ClientPage } from '~/components/embed/embed-document-signing-page-v1';
|
||||
import { EmbedSignDocumentV2ClientPage } from '~/components/embed/embed-document-signing-page-v2';
|
||||
import { DocumentSigningAuthProvider } from '~/components/general/document-signing/document-signing-auth-provider';
|
||||
import { DocumentSigningProvider } from '~/components/general/document-signing/document-signing-provider';
|
||||
import { EnvelopeSigningProvider } from '~/components/general/document-signing/envelope-signing-provider';
|
||||
import { superLoaderJson, useSuperLoaderData } from '~/utils/super-json-loader';
|
||||
|
||||
import { getOptionalLoaderContext } from '../../../../server/utils/get-loader-session';
|
||||
import type { Route } from './+types/sign.$token';
|
||||
|
||||
async function handleV1Loader({ params, request }: Route.LoaderArgs) {
|
||||
const { requestMetadata } = getOptionalLoaderContext();
|
||||
|
||||
if (!params.token) {
|
||||
throw new Response('Not found', { status: 404 });
|
||||
}
|
||||
|
||||
const token = params.token;
|
||||
|
||||
const { user } = await getOptionalSession(request);
|
||||
|
||||
const [document, fields, recipient, completedFields] = await Promise.all([
|
||||
getDocumentAndSenderByToken({
|
||||
token,
|
||||
userId: user?.id,
|
||||
requireAccessAuth: false,
|
||||
}).catch(() => null),
|
||||
getFieldsForToken({ token }),
|
||||
getRecipientByToken({ token }).catch(() => null),
|
||||
getCompletedFieldsForToken({ token }).catch(() => []),
|
||||
]);
|
||||
|
||||
// `document.directLink` is always available but we're doing this to
|
||||
// satisfy the type checker.
|
||||
if (!document || !recipient) {
|
||||
throw new Response('Not found', { status: 404 });
|
||||
}
|
||||
|
||||
const organisationClaim = await getOrganisationClaimByTeamId({ teamId: document.teamId });
|
||||
|
||||
const allowEmbedSigningWhitelabel = organisationClaim.flags.embedSigningWhiteLabel;
|
||||
const hidePoweredBy = organisationClaim.flags.hidePoweredBy;
|
||||
|
||||
// TODO: Make this more robust, we need to ensure the owner is either
|
||||
// TODO: the member of a team that has an active subscription, is an early
|
||||
// TODO: adopter or is an enterprise user.
|
||||
if (IS_BILLING_ENABLED() && !organisationClaim.flags.embedSigning) {
|
||||
throw data(
|
||||
{
|
||||
type: 'embed-paywall',
|
||||
},
|
||||
{
|
||||
status: 403,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const { derivedRecipientAccessAuth } = extractDocumentAuthMethods({
|
||||
documentAuth: document.authOptions,
|
||||
});
|
||||
|
||||
const isAccessAuthValid = derivedRecipientAccessAuth.every((accesssAuth) =>
|
||||
match(accesssAuth)
|
||||
.with(DocumentAccessAuth.ACCOUNT, () => user && user.email === recipient.email)
|
||||
.with(DocumentAccessAuth.TWO_FACTOR_AUTH, () => true) // Allow without account requirement
|
||||
.exhaustive(),
|
||||
);
|
||||
|
||||
if (!isAccessAuthValid) {
|
||||
throw data(
|
||||
{
|
||||
type: 'embed-authentication-required',
|
||||
email: user?.email || recipient.email,
|
||||
returnTo: `/embed/sign/${token}`,
|
||||
},
|
||||
{
|
||||
status: 401,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const isRecipientsTurnToSign = await getIsRecipientsTurnToSign({ token });
|
||||
|
||||
if (!isRecipientsTurnToSign) {
|
||||
throw data(
|
||||
{
|
||||
type: 'embed-waiting-for-turn',
|
||||
},
|
||||
{
|
||||
status: 403,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
await viewedDocument({
|
||||
token,
|
||||
requestMetadata,
|
||||
recipientAccessAuth: derivedRecipientAccessAuth,
|
||||
});
|
||||
|
||||
const allRecipients =
|
||||
recipient.role === RecipientRole.ASSISTANT
|
||||
? await getRecipientsForAssistant({
|
||||
token,
|
||||
})
|
||||
: [];
|
||||
|
||||
return {
|
||||
token,
|
||||
user,
|
||||
document,
|
||||
allRecipients,
|
||||
recipient,
|
||||
fields,
|
||||
completedFields,
|
||||
hidePoweredBy,
|
||||
allowEmbedSigningWhitelabel,
|
||||
};
|
||||
}
|
||||
|
||||
async function handleV2Loader({ params, request }: Route.LoaderArgs) {
|
||||
const { requestMetadata } = getOptionalLoaderContext();
|
||||
|
||||
if (!params.token) {
|
||||
throw new Response('Not found', { status: 404 });
|
||||
}
|
||||
|
||||
const token = params.token;
|
||||
|
||||
const { user } = await getOptionalSession(request);
|
||||
|
||||
const envelopeForSigning = await getEnvelopeForRecipientSigning({
|
||||
token,
|
||||
userId: user?.id,
|
||||
})
|
||||
.then((envelopeForSigning) => {
|
||||
return {
|
||||
isDocumentAccessValid: true,
|
||||
...envelopeForSigning,
|
||||
} as const;
|
||||
})
|
||||
.catch(async (e) => {
|
||||
const error = AppError.parseError(e);
|
||||
|
||||
if (error.code === AppErrorCode.UNAUTHORIZED) {
|
||||
const requiredAccessData = await getEnvelopeRequiredAccessData({ token });
|
||||
|
||||
return {
|
||||
isDocumentAccessValid: false,
|
||||
...requiredAccessData,
|
||||
} as const;
|
||||
}
|
||||
|
||||
throw new Response('Not Found', { status: 404 });
|
||||
});
|
||||
|
||||
if (!envelopeForSigning.isDocumentAccessValid) {
|
||||
throw data(
|
||||
{
|
||||
type: 'embed-authentication-required',
|
||||
email: envelopeForSigning.recipientEmail,
|
||||
returnTo: `/embed/sign/${token}`,
|
||||
},
|
||||
{
|
||||
status: 401,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const { envelope, recipient, isRecipientsTurn } = envelopeForSigning;
|
||||
|
||||
const organisationClaim = await getOrganisationClaimByTeamId({ teamId: envelope.teamId });
|
||||
|
||||
const allowEmbedSigningWhitelabel = organisationClaim.flags.embedSigningWhiteLabel;
|
||||
const hidePoweredBy = organisationClaim.flags.hidePoweredBy;
|
||||
|
||||
if (IS_BILLING_ENABLED() && !organisationClaim.flags.embedSigning) {
|
||||
throw data(
|
||||
{
|
||||
type: 'embed-paywall',
|
||||
},
|
||||
{
|
||||
status: 403,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
if (!isRecipientsTurn) {
|
||||
throw data(
|
||||
{
|
||||
type: 'embed-waiting-for-turn',
|
||||
},
|
||||
{
|
||||
status: 403,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const { derivedRecipientAccessAuth } = extractDocumentAuthMethods({
|
||||
documentAuth: envelope.authOptions,
|
||||
recipientAuth: recipient.authOptions,
|
||||
});
|
||||
|
||||
const isAccessAuthValid = derivedRecipientAccessAuth.every((accesssAuth) =>
|
||||
match(accesssAuth)
|
||||
.with(DocumentAccessAuth.ACCOUNT, () => user && user.email === recipient.email)
|
||||
.with(DocumentAccessAuth.TWO_FACTOR_AUTH, () => true)
|
||||
.exhaustive(),
|
||||
);
|
||||
|
||||
if (!isAccessAuthValid) {
|
||||
throw data(
|
||||
{
|
||||
type: 'embed-authentication-required',
|
||||
email: user?.email || recipient.email,
|
||||
returnTo: `/embed/sign/${token}`,
|
||||
},
|
||||
{
|
||||
status: 401,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
await viewedDocument({
|
||||
token,
|
||||
requestMetadata,
|
||||
recipientAccessAuth: derivedRecipientAccessAuth,
|
||||
}).catch(() => null);
|
||||
|
||||
return {
|
||||
token,
|
||||
user,
|
||||
envelopeForSigning,
|
||||
hidePoweredBy,
|
||||
allowEmbedSigningWhitelabel,
|
||||
};
|
||||
}
|
||||
|
||||
export async function loader(loaderArgs: Route.LoaderArgs) {
|
||||
const { token } = loaderArgs.params;
|
||||
|
||||
if (!token) {
|
||||
throw new Response('Not Found', { status: 404 });
|
||||
}
|
||||
|
||||
// Not efficient but works for now until we remove v1.
|
||||
const foundRecipient = await prisma.recipient.findFirst({
|
||||
where: {
|
||||
token,
|
||||
},
|
||||
select: {
|
||||
envelope: {
|
||||
select: {
|
||||
internalVersion: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!foundRecipient) {
|
||||
throw new Response('Not Found', { status: 404 });
|
||||
}
|
||||
|
||||
if (foundRecipient.envelope.internalVersion === 2) {
|
||||
const payloadV2 = await handleV2Loader(loaderArgs);
|
||||
|
||||
return superLoaderJson({
|
||||
version: 2,
|
||||
payload: payloadV2,
|
||||
} as const);
|
||||
}
|
||||
|
||||
const payloadV1 = await handleV1Loader(loaderArgs);
|
||||
|
||||
return superLoaderJson({
|
||||
version: 1,
|
||||
payload: payloadV1,
|
||||
} as const);
|
||||
}
|
||||
|
||||
export default function EmbedSignDocumentPage() {
|
||||
const { version, payload } = useSuperLoaderData<typeof loader>();
|
||||
|
||||
if (version === 1) {
|
||||
return <EmbedSignDocumentPageV1 data={payload} />;
|
||||
}
|
||||
|
||||
return <EmbedSignDocumentPageV2 data={payload} />;
|
||||
}
|
||||
|
||||
const EmbedSignDocumentPageV1 = ({
|
||||
data,
|
||||
}: {
|
||||
data: Awaited<ReturnType<typeof handleV1Loader>>;
|
||||
}) => {
|
||||
const {
|
||||
token,
|
||||
user,
|
||||
document,
|
||||
allRecipients,
|
||||
recipient,
|
||||
fields,
|
||||
completedFields,
|
||||
hidePoweredBy,
|
||||
allowEmbedSigningWhitelabel,
|
||||
} = data;
|
||||
|
||||
return (
|
||||
<DocumentSigningProvider
|
||||
email={recipient.email}
|
||||
fullName={user?.email === recipient.email ? user?.name : recipient.name}
|
||||
signature={user?.email === recipient.email ? user?.signature : undefined}
|
||||
typedSignatureEnabled={document.documentMeta?.typedSignatureEnabled}
|
||||
uploadSignatureEnabled={document.documentMeta?.uploadSignatureEnabled}
|
||||
drawSignatureEnabled={document.documentMeta?.drawSignatureEnabled}
|
||||
>
|
||||
<DocumentSigningAuthProvider
|
||||
documentAuthOptions={document.authOptions}
|
||||
recipient={recipient}
|
||||
user={user}
|
||||
>
|
||||
<EmbedSignDocumentV1ClientPage
|
||||
token={token}
|
||||
documentId={document.id}
|
||||
envelopeId={document.envelopeId}
|
||||
envelopeItems={document.envelopeItems}
|
||||
recipient={recipient}
|
||||
fields={fields}
|
||||
completedFields={completedFields}
|
||||
metadata={document.documentMeta}
|
||||
isCompleted={isDocumentCompleted(document.status)}
|
||||
hidePoweredBy={hidePoweredBy}
|
||||
allowWhitelabelling={allowEmbedSigningWhitelabel}
|
||||
allRecipients={allRecipients}
|
||||
/>
|
||||
</DocumentSigningAuthProvider>
|
||||
</DocumentSigningProvider>
|
||||
);
|
||||
};
|
||||
|
||||
const EmbedSignDocumentPageV2 = ({
|
||||
data,
|
||||
}: {
|
||||
data: Awaited<ReturnType<typeof handleV2Loader>>;
|
||||
}) => {
|
||||
const { token, user, envelopeForSigning, hidePoweredBy, allowEmbedSigningWhitelabel } = data;
|
||||
|
||||
const { envelope, recipient } = envelopeForSigning;
|
||||
|
||||
return (
|
||||
<EnvelopeSigningProvider
|
||||
envelopeData={envelopeForSigning}
|
||||
email={recipient.email}
|
||||
fullName={user?.email === recipient.email ? user?.name : recipient.name}
|
||||
signature={user?.email === recipient.email ? user?.signature : undefined}
|
||||
>
|
||||
<DocumentSigningAuthProvider
|
||||
documentAuthOptions={envelope.authOptions}
|
||||
recipient={recipient}
|
||||
user={user}
|
||||
>
|
||||
<EnvelopeRenderProvider envelope={envelope} token={token}>
|
||||
<EmbedSignDocumentV2ClientPage
|
||||
hidePoweredBy={hidePoweredBy}
|
||||
allowWhitelabelling={allowEmbedSigningWhitelabel}
|
||||
/>
|
||||
</EnvelopeRenderProvider>
|
||||
</DocumentSigningAuthProvider>
|
||||
</EnvelopeSigningProvider>
|
||||
);
|
||||
};
|
||||
@ -1,181 +0,0 @@
|
||||
import { RecipientRole } from '@prisma/client';
|
||||
import { data } from 'react-router';
|
||||
import { getOptionalLoaderContext } from 'server/utils/get-loader-session';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session';
|
||||
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
|
||||
import { getDocumentAndSenderByToken } from '@documenso/lib/server-only/document/get-document-by-token';
|
||||
import { viewedDocument } from '@documenso/lib/server-only/document/viewed-document';
|
||||
import { getCompletedFieldsForToken } from '@documenso/lib/server-only/field/get-completed-fields-for-token';
|
||||
import { getFieldsForToken } from '@documenso/lib/server-only/field/get-fields-for-token';
|
||||
import { getOrganisationClaimByTeamId } from '@documenso/lib/server-only/organisation/get-organisation-claims';
|
||||
import { getIsRecipientsTurnToSign } from '@documenso/lib/server-only/recipient/get-is-recipient-turn';
|
||||
import { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-token';
|
||||
import { getRecipientsForAssistant } from '@documenso/lib/server-only/recipient/get-recipients-for-assistant';
|
||||
import { DocumentAccessAuth } from '@documenso/lib/types/document-auth';
|
||||
import { isDocumentCompleted } from '@documenso/lib/utils/document';
|
||||
import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth';
|
||||
|
||||
import { EmbedSignDocumentClientPage } from '~/components/embed/embed-document-signing-page';
|
||||
import { DocumentSigningAuthProvider } from '~/components/general/document-signing/document-signing-auth-provider';
|
||||
import { DocumentSigningProvider } from '~/components/general/document-signing/document-signing-provider';
|
||||
import { superLoaderJson, useSuperLoaderData } from '~/utils/super-json-loader';
|
||||
|
||||
import type { Route } from './+types/sign.$url';
|
||||
|
||||
export async function loader({ params, request }: Route.LoaderArgs) {
|
||||
const { requestMetadata } = getOptionalLoaderContext();
|
||||
|
||||
if (!params.url) {
|
||||
throw new Response('Not found', { status: 404 });
|
||||
}
|
||||
|
||||
const token = params.url;
|
||||
|
||||
const { user } = await getOptionalSession(request);
|
||||
|
||||
const [document, fields, recipient, completedFields] = await Promise.all([
|
||||
getDocumentAndSenderByToken({
|
||||
token,
|
||||
userId: user?.id,
|
||||
requireAccessAuth: false,
|
||||
}).catch(() => null),
|
||||
getFieldsForToken({ token }),
|
||||
getRecipientByToken({ token }).catch(() => null),
|
||||
getCompletedFieldsForToken({ token }).catch(() => []),
|
||||
]);
|
||||
|
||||
// `document.directLink` is always available but we're doing this to
|
||||
// satisfy the type checker.
|
||||
if (!document || !recipient) {
|
||||
throw new Response('Not found', { status: 404 });
|
||||
}
|
||||
|
||||
const organisationClaim = await getOrganisationClaimByTeamId({ teamId: document.teamId });
|
||||
|
||||
const allowEmbedSigningWhitelabel = organisationClaim.flags.embedSigningWhiteLabel;
|
||||
const hidePoweredBy = organisationClaim.flags.hidePoweredBy;
|
||||
|
||||
// TODO: Make this more robust, we need to ensure the owner is either
|
||||
// TODO: the member of a team that has an active subscription, is an early
|
||||
// TODO: adopter or is an enterprise user.
|
||||
if (IS_BILLING_ENABLED() && !organisationClaim.flags.embedSigning) {
|
||||
throw data(
|
||||
{
|
||||
type: 'embed-paywall',
|
||||
},
|
||||
{
|
||||
status: 403,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const { derivedRecipientAccessAuth } = extractDocumentAuthMethods({
|
||||
documentAuth: document.authOptions,
|
||||
});
|
||||
|
||||
const isAccessAuthValid = derivedRecipientAccessAuth.every((accesssAuth) =>
|
||||
match(accesssAuth)
|
||||
.with(DocumentAccessAuth.ACCOUNT, () => user && user.email === recipient.email)
|
||||
.with(DocumentAccessAuth.TWO_FACTOR_AUTH, () => true) // Allow without account requirement
|
||||
.exhaustive(),
|
||||
);
|
||||
|
||||
if (!isAccessAuthValid) {
|
||||
throw data(
|
||||
{
|
||||
type: 'embed-authentication-required',
|
||||
email: user?.email || recipient.email,
|
||||
returnTo: `/embed/sign/${token}`,
|
||||
},
|
||||
{
|
||||
status: 401,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const isRecipientsTurnToSign = await getIsRecipientsTurnToSign({ token });
|
||||
|
||||
if (!isRecipientsTurnToSign) {
|
||||
throw data(
|
||||
{
|
||||
type: 'embed-waiting-for-turn',
|
||||
},
|
||||
{
|
||||
status: 403,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
await viewedDocument({
|
||||
token,
|
||||
requestMetadata,
|
||||
recipientAccessAuth: derivedRecipientAccessAuth,
|
||||
});
|
||||
|
||||
const allRecipients =
|
||||
recipient.role === RecipientRole.ASSISTANT
|
||||
? await getRecipientsForAssistant({
|
||||
token,
|
||||
})
|
||||
: [];
|
||||
|
||||
return superLoaderJson({
|
||||
token,
|
||||
user,
|
||||
document,
|
||||
allRecipients,
|
||||
recipient,
|
||||
fields,
|
||||
completedFields,
|
||||
hidePoweredBy,
|
||||
allowEmbedSigningWhitelabel,
|
||||
});
|
||||
}
|
||||
|
||||
export default function EmbedSignDocumentPage() {
|
||||
const {
|
||||
token,
|
||||
user,
|
||||
document,
|
||||
allRecipients,
|
||||
recipient,
|
||||
fields,
|
||||
completedFields,
|
||||
hidePoweredBy,
|
||||
allowEmbedSigningWhitelabel,
|
||||
} = useSuperLoaderData<typeof loader>();
|
||||
|
||||
return (
|
||||
<DocumentSigningProvider
|
||||
email={recipient.email}
|
||||
fullName={user?.email === recipient.email ? user?.name : recipient.name}
|
||||
signature={user?.email === recipient.email ? user?.signature : undefined}
|
||||
typedSignatureEnabled={document.documentMeta?.typedSignatureEnabled}
|
||||
uploadSignatureEnabled={document.documentMeta?.uploadSignatureEnabled}
|
||||
drawSignatureEnabled={document.documentMeta?.drawSignatureEnabled}
|
||||
>
|
||||
<DocumentSigningAuthProvider
|
||||
documentAuthOptions={document.authOptions}
|
||||
recipient={recipient}
|
||||
user={user}
|
||||
>
|
||||
<EmbedSignDocumentClientPage
|
||||
token={token}
|
||||
documentId={document.id}
|
||||
envelopeId={document.envelopeId}
|
||||
documentData={document.documentData}
|
||||
recipient={recipient}
|
||||
fields={fields}
|
||||
completedFields={completedFields}
|
||||
metadata={document.documentMeta}
|
||||
isCompleted={isDocumentCompleted(document.status)}
|
||||
hidePoweredBy={hidePoweredBy}
|
||||
allowWhitelabelling={allowEmbedSigningWhitelabel}
|
||||
allRecipients={allRecipients}
|
||||
/>
|
||||
</DocumentSigningAuthProvider>
|
||||
</DocumentSigningProvider>
|
||||
);
|
||||
}
|
||||
@ -67,6 +67,7 @@ export async function loader({ request }: Route.LoaderArgs) {
|
||||
export default function MultisignPage() {
|
||||
const { envelopes, user, hidePoweredBy, allowWhitelabelling } =
|
||||
useSuperLoaderData<typeof loader>();
|
||||
|
||||
const revalidator = useRevalidator();
|
||||
|
||||
const [selectedDocument, setSelectedDocument] = useState<
|
||||
|
||||
Reference in New Issue
Block a user