feat: add envelopes (#2025)

This PR is handles the changes required to support envelopes. The new
envelope editor/signing page will be hidden during release.

The core changes here is to migrate the documents and templates model to
a centralized envelopes model.

Even though Documents and Templates are removed, from the user
perspective they will still exist as we remap envelopes to documents and
templates.
This commit is contained in:
David Nguyen
2025-10-14 21:56:36 +11:00
committed by GitHub
parent 7b17156e56
commit 7f09ba72f4
447 changed files with 33467 additions and 9622 deletions

View File

@ -7,6 +7,7 @@ import { OrganisationProvider } from '@documenso/lib/client-only/providers/organ
import { useSession } from '@documenso/lib/client-only/providers/session';
import { getSiteSettings } from '@documenso/lib/server-only/site-settings/get-site-settings';
import { SITE_SETTINGS_BANNER_ID } from '@documenso/lib/server-only/site-settings/schemas/banner';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import { AppBanner } from '~/components/general/app-banner';
@ -42,7 +43,7 @@ export async function loader({ request }: Route.LoaderArgs) {
};
}
export default function Layout({ loaderData, params }: Route.ComponentProps) {
export default function Layout({ loaderData, params, matches }: Route.ComponentProps) {
const { banner } = loaderData;
const { user, organisations } = useSession();
@ -71,6 +72,13 @@ export default function Layout({ loaderData, params }: Route.ComponentProps) {
const orgNotFound = params.orgUrl && !currentOrganisation;
const teamNotFound = params.teamUrl && !currentTeam;
// Hide the header for editor routes.
const hideHeader = matches.some(
(match) =>
match?.id === 'routes/_authenticated+/t.$teamUrl+/documents.$id.edit' ||
match?.id === 'routes/_authenticated+/t.$teamUrl+/templates.$id.edit',
);
if (orgNotFound || teamNotFound) {
return (
<GenericErrorLayout
@ -110,9 +118,13 @@ export default function Layout({ loaderData, params }: Route.ComponentProps) {
{banner && <AppBanner banner={banner} />}
<Header />
{!hideHeader && <Header />}
<main className="mt-8 pb-8 md:mt-12 md:pb-12">
<main
className={cn({
'mt-8 pb-8 md:mt-12 md:pb-12': !hideHeader,
})}
>
<Outlet />
</main>
</TeamProvider>

View File

@ -1,11 +1,11 @@
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { SigningStatus } from '@prisma/client';
import { EnvelopeType, SigningStatus } from '@prisma/client';
import { DateTime } from 'luxon';
import { Link, redirect } from 'react-router';
import { getEntireDocument } from '@documenso/lib/server-only/admin/get-entire-document';
import { unsafeGetEntireEnvelope } from '@documenso/lib/server-only/admin/get-entire-document';
import { trpc } from '@documenso/trpc/react';
import {
Accordion,
@ -25,24 +25,31 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
import { AdminDocumentDeleteDialog } from '~/components/dialogs/admin-document-delete-dialog';
import { DocumentStatus } from '~/components/general/document/document-status';
import { AdminDocumentJobsTable } from '~/components/tables/admin-document-jobs-table';
import { AdminDocumentRecipientItemTable } from '~/components/tables/admin-document-recipient-item-table';
import type { Route } from './+types/documents.$id';
export async function loader({ params }: Route.LoaderArgs) {
const id = Number(params.id);
const id = params.id;
if (isNaN(id)) {
if (!id || !id.startsWith('envelope_')) {
throw redirect('/admin/documents');
}
const document = await getEntireDocument({ id });
const envelope = await unsafeGetEntireEnvelope({
id: {
type: 'envelopeId',
id,
},
type: EnvelopeType.DOCUMENT,
});
return { document };
return { envelope };
}
export default function AdminDocumentDetailsPage({ loaderData }: Route.ComponentProps) {
const { document } = loaderData;
const { envelope } = loaderData;
const { _, i18n } = useLingui();
const { toast } = useToast();
@ -51,8 +58,8 @@ export default function AdminDocumentDetailsPage({ loaderData }: Route.Component
trpc.admin.document.reseal.useMutation({
onSuccess: () => {
toast({
title: _(msg`Success`),
description: _(msg`Document resealed`),
title: _(msg`Sealing job started`),
description: _(msg`See the background jobs tab for the status`),
});
},
onError: () => {
@ -68,11 +75,11 @@ export default function AdminDocumentDetailsPage({ loaderData }: Route.Component
<div>
<div className="flex items-start justify-between">
<div className="flex items-center gap-x-4">
<h1 className="text-2xl font-semibold">{document.title}</h1>
<DocumentStatus status={document.status} />
<h1 className="text-2xl font-semibold">{envelope.title}</h1>
<DocumentStatus status={envelope.status} />
</div>
{document.deletedAt && (
{envelope.deletedAt && (
<Badge size="large" variant="destructive">
<Trans>Deleted</Trans>
</Badge>
@ -81,11 +88,11 @@ export default function AdminDocumentDetailsPage({ loaderData }: Route.Component
<div className="text-muted-foreground mt-4 text-sm">
<div>
<Trans>Created on</Trans>: {i18n.date(document.createdAt, DateTime.DATETIME_MED)}
<Trans>Created on</Trans>: {i18n.date(envelope.createdAt, DateTime.DATETIME_MED)}
</div>
<div>
<Trans>Last updated at</Trans>: {i18n.date(document.updatedAt, DateTime.DATETIME_MED)}
<Trans>Last updated at</Trans>: {i18n.date(envelope.updatedAt, DateTime.DATETIME_MED)}
</div>
</div>
@ -102,12 +109,12 @@ export default function AdminDocumentDetailsPage({ loaderData }: Route.Component
<Button
variant="outline"
loading={isResealDocumentLoading}
disabled={document.recipients.some(
disabled={envelope.recipients.some(
(recipient) =>
recipient.signingStatus !== SigningStatus.SIGNED &&
recipient.signingStatus !== SigningStatus.REJECTED,
)}
onClick={() => resealDocument({ id: document.id })}
onClick={() => resealDocument({ id: envelope.id })}
>
<Trans>Reseal document</Trans>
</Button>
@ -123,7 +130,7 @@ export default function AdminDocumentDetailsPage({ loaderData }: Route.Component
</TooltipProvider>
<Button variant="outline" asChild>
<Link to={`/admin/users/${document.userId}`}>
<Link to={`/admin/users/${envelope.userId}`}>
<Trans>Go to owner</Trans>
</Link>
</Button>
@ -136,7 +143,7 @@ export default function AdminDocumentDetailsPage({ loaderData }: Route.Component
<div className="mt-4">
<Accordion type="multiple" className="space-y-4">
{document.recipients.map((recipient) => (
{envelope.recipients.map((recipient) => (
<AccordionItem
key={recipient.id}
value={recipient.id.toString()}
@ -161,7 +168,13 @@ export default function AdminDocumentDetailsPage({ loaderData }: Route.Component
<hr className="my-4" />
{document && <AdminDocumentDeleteDialog document={document} />}
<div className="mt-4">
<AdminDocumentJobsTable envelopeId={envelope.id} />
</div>
<hr className="my-4" />
{envelope && <AdminDocumentDeleteDialog envelopeId={envelope.id} />}
</div>
);
}

View File

@ -64,7 +64,7 @@ export default function AdminDocumentsPage() {
cell: ({ row }) => {
return (
<Link
to={`/admin/documents/${row.original.id}`}
to={`/admin/documents/${row.original.envelopeId}`}
className="block max-w-[5rem] truncate font-medium hover:underline md:max-w-[10rem]"
>
{row.original.title}

View File

@ -1,21 +1,25 @@
import { useLingui } from '@lingui/react';
import { Plural, Trans } from '@lingui/react/macro';
import { DocumentStatus, TeamMemberRole } from '@prisma/client';
import { msg } from '@lingui/core/macro';
import { Plural, Trans, useLingui } from '@lingui/react/macro';
import { DocumentStatus } from '@prisma/client';
import { ChevronLeft, Users2 } from 'lucide-react';
import { Link, redirect } from 'react-router';
import { Link } from 'react-router';
import { match } from 'ts-pattern';
import { getSession } from '@documenso/auth/server/lib/utils/get-session';
import { EnvelopeRenderProvider } from '@documenso/lib/client-only/providers/envelope-render-provider';
import { useSession } from '@documenso/lib/client-only/providers/session';
import { getDocumentWithDetailsById } from '@documenso/lib/server-only/document/get-document-with-details-by-id';
import { getTeamByUrl } from '@documenso/lib/server-only/team/get-team';
import { DocumentVisibility } from '@documenso/lib/types/document-visibility';
import { logDocumentAccess } from '@documenso/lib/utils/logger';
import { mapSecondaryIdToDocumentId } from '@documenso/lib/utils/envelope';
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
import { DocumentReadOnlyFields } from '@documenso/ui/components/document/document-read-only-fields';
import { trpc } from '@documenso/trpc/react';
import {
DocumentReadOnlyFields,
mapFieldsWithRecipients,
} from '@documenso/ui/components/document/document-read-only-fields';
import PDFViewerKonvaLazy from '@documenso/ui/components/pdf-viewer/pdf-viewer-konva-lazy';
import { Badge } from '@documenso/ui/primitives/badge';
import { Button } from '@documenso/ui/primitives/button';
import { Card, CardContent } from '@documenso/ui/primitives/card';
import { PDFViewer } from '@documenso/ui/primitives/pdf-viewer';
import { Spinner } from '@documenso/ui/primitives/spinner';
import { DocumentPageViewButton } from '~/components/general/document/document-page-view-button';
import { DocumentPageViewDropdown } from '~/components/general/document/document-page-view-dropdown';
@ -27,90 +31,66 @@ import {
DocumentStatus as DocumentStatusComponent,
FRIENDLY_STATUS_MAP,
} from '~/components/general/document/document-status';
import { EnvelopeRendererFileSelector } from '~/components/general/envelope-editor/envelope-file-selector';
import EnvelopeGenericPageRenderer from '~/components/general/envelope-editor/envelope-generic-page-renderer';
import { GenericErrorLayout } from '~/components/general/generic-error-layout';
import { StackAvatarsWithTooltip } from '~/components/general/stack-avatars-with-tooltip';
import { superLoaderJson, useSuperLoaderData } from '~/utils/super-json-loader';
import { useCurrentTeam } from '~/providers/team';
import type { Route } from './+types/documents.$id._index';
export async function loader({ params, request }: Route.LoaderArgs) {
const { user } = await getSession(request);
export default function DocumentPage({ params }: Route.ComponentProps) {
const { t } = useLingui();
const { user } = useSession();
const teamUrl = params.teamUrl;
const team = useCurrentTeam();
if (!teamUrl) {
throw new Response('Not Found', { status: 404 });
const {
data: envelope,
isLoading: isLoadingEnvelope,
isError: isErrorEnvelope,
} = trpc.envelope.get.useQuery({
envelopeId: params.id,
});
if (isLoadingEnvelope) {
return (
<div className="text-foreground flex w-screen flex-col items-center justify-center gap-2 py-64">
<Spinner />
<Trans>Loading</Trans>
</div>
);
}
const team = await getTeamByUrl({ userId: user.id, teamUrl });
const { id } = params;
const documentId = Number(id);
if (isErrorEnvelope || !envelope) {
return (
<GenericErrorLayout
errorCode={404}
errorCodeMap={{
404: {
heading: msg`Not found`,
subHeading: msg`404 Not found`,
message: msg`The document you are looking for may have been removed, renamed or may have never
existed.`,
},
}}
primaryButton={
<Button asChild>
<Link to={`/t/${team.url}/documents`}>
<Trans>Go back</Trans>
</Link>
</Button>
}
/>
);
}
const documentRootPath = formatDocumentsPath(team.url);
if (!documentId || Number.isNaN(documentId)) {
throw redirect(documentRootPath);
}
const document = await getDocumentWithDetailsById({
documentId,
userId: user.id,
teamId: team.id,
}).catch(() => null);
// Todo: 401 or 404 page.
if (!document) {
throw redirect(documentRootPath);
}
const documentVisibility = document?.visibility;
const currentTeamMemberRole = team.currentTeamRole;
const isRecipient = document?.recipients.find((recipient) => recipient.email === user.email);
let canAccessDocument = true;
if (!isRecipient && document?.userId !== user.id) {
canAccessDocument = match([documentVisibility, currentTeamMemberRole])
.with([DocumentVisibility.EVERYONE, TeamMemberRole.ADMIN], () => true)
.with([DocumentVisibility.EVERYONE, TeamMemberRole.MANAGER], () => true)
.with([DocumentVisibility.EVERYONE, TeamMemberRole.MEMBER], () => true)
.with([DocumentVisibility.MANAGER_AND_ABOVE, TeamMemberRole.ADMIN], () => true)
.with([DocumentVisibility.MANAGER_AND_ABOVE, TeamMemberRole.MANAGER], () => true)
.with([DocumentVisibility.ADMIN, TeamMemberRole.ADMIN], () => true)
.otherwise(() => false);
}
if (!document || !document.documentData || !canAccessDocument) {
throw redirect(documentRootPath);
}
logDocumentAccess({
request,
documentId,
userId: user.id,
});
return superLoaderJson({
document,
documentRootPath,
});
}
export default function DocumentPage() {
const loaderData = useSuperLoaderData<typeof loader>();
const { _ } = useLingui();
const { user } = useSession();
const { document, documentRootPath } = loaderData;
const { recipients, documentData, documentMeta } = document;
return (
<div className="mx-auto -mt-4 w-full max-w-screen-xl px-4 md:px-8">
{document.status === DocumentStatus.PENDING && (
<DocumentRecipientLinkCopyDialog recipients={recipients} />
{envelope.status === DocumentStatus.PENDING && (
<DocumentRecipientLinkCopyDialog recipients={envelope.recipients} />
)}
<Link to={documentRootPath} className="flex items-center text-[#7AC455] hover:opacity-80">
@ -122,35 +102,35 @@ export default function DocumentPage() {
<div>
<h1
className="mt-4 block max-w-[20rem] truncate text-2xl font-semibold md:max-w-[30rem] md:text-3xl"
title={document.title}
title={envelope.title}
>
{document.title}
{envelope.title}
</h1>
<div className="mt-2.5 flex items-center gap-x-6">
<DocumentStatusComponent
inheritColor
status={document.status}
status={envelope.status}
className="text-muted-foreground"
/>
{recipients.length > 0 && (
{envelope.recipients.length > 0 && (
<div className="text-muted-foreground flex items-center">
<Users2 className="mr-2 h-5 w-5" />
<StackAvatarsWithTooltip
recipients={recipients}
documentStatus={document.status}
recipients={envelope.recipients}
documentStatus={envelope.status}
position="bottom"
>
<span>
<Trans>{recipients.length} Recipient(s)</Trans>
<Trans>{envelope.recipients.length} Recipient(s)</Trans>
</span>
</StackAvatarsWithTooltip>
</div>
)}
{document.deletedAt && (
{envelope.deletedAt && (
<Badge variant="destructive">
<Trans>Document deleted</Trans>
</Badge>
@ -165,33 +145,47 @@ export default function DocumentPage() {
gradient
>
<CardContent className="p-2">
<PDFViewer document={document} key={documentData.id} documentData={documentData} />
{envelope.internalVersion === 2 ? (
<EnvelopeRenderProvider envelope={envelope} fields={envelope.fields}>
<EnvelopeRendererFileSelector fields={envelope.fields} className="mb-4 p-0" />
<PDFViewerKonvaLazy customPageRenderer={EnvelopeGenericPageRenderer} />
</EnvelopeRenderProvider>
) : (
<>
{envelope.status !== DocumentStatus.COMPLETED && (
<DocumentReadOnlyFields
fields={mapFieldsWithRecipients(envelope.fields, envelope.recipients)}
documentMeta={envelope.documentMeta || undefined}
showRecipientTooltip={true}
showRecipientColors={true}
recipientIds={envelope.recipients.map((recipient) => recipient.id)}
/>
)}
<PDFViewer
document={envelope}
key={envelope.envelopeItems[0].id}
documentData={envelope.envelopeItems[0].documentData}
/>
</>
)}
</CardContent>
</Card>
{document.status !== DocumentStatus.COMPLETED && (
<DocumentReadOnlyFields
fields={document.fields}
documentMeta={documentMeta || undefined}
showRecipientTooltip={true}
showRecipientColors={true}
recipientIds={recipients.map((recipient) => recipient.id)}
/>
)}
<div className="col-span-12 lg:col-span-6 xl:col-span-5">
<div className="space-y-6">
<section className="border-border bg-widget flex flex-col rounded-xl border pb-4 pt-6">
<div className="flex flex-row items-center justify-between px-4">
<h3 className="text-foreground text-2xl font-semibold">
{_(FRIENDLY_STATUS_MAP[document.status].labelExtended)}
{t(FRIENDLY_STATUS_MAP[envelope.status].labelExtended)}
</h3>
<DocumentPageViewDropdown document={document} />
<DocumentPageViewDropdown envelope={envelope} />
</div>
<p className="text-muted-foreground mt-2 px-4 text-sm">
{match(document.status)
{match(envelope.status)
.with(DocumentStatus.COMPLETED, () => (
<Trans>This document has been signed by all recipients</Trans>
))
@ -202,7 +196,7 @@ export default function DocumentPage() {
<Trans>This document is currently a draft and has not been sent</Trans>
))
.with(DocumentStatus.PENDING, () => {
const pendingRecipients = recipients.filter(
const pendingRecipients = envelope.recipients.filter(
(recipient) => recipient.signingStatus === 'NOT_SIGNED',
);
@ -218,18 +212,21 @@ export default function DocumentPage() {
</p>
<div className="mt-4 border-t px-4 pt-4">
<DocumentPageViewButton document={document} />
<DocumentPageViewButton envelope={envelope} />
</div>
</section>
{/* Document information section. */}
<DocumentPageViewInformation document={document} userId={user.id} />
<DocumentPageViewInformation envelope={envelope} userId={user.id} />
{/* Recipients section. */}
<DocumentPageViewRecipients document={document} documentRootPath={documentRootPath} />
<DocumentPageViewRecipients envelope={envelope} documentRootPath={documentRootPath} />
{/* Recent activity section. */}
<DocumentPageViewRecentActivity documentId={document.id} userId={user.id} />
<DocumentPageViewRecentActivity
documentId={mapSecondaryIdToDocumentId(envelope.secondaryId)}
userId={user.id}
/>
</div>
</div>
</div>

View File

@ -0,0 +1,90 @@
import { msg } from '@lingui/core/macro';
import { Trans } from '@lingui/react/macro';
import { EnvelopeType } from '@prisma/client';
import { ChevronLeftIcon } from 'lucide-react';
import { Link, Outlet, isRouteErrorResponse, redirect } from 'react-router';
import { getSession } from '@documenso/auth/server/lib/utils/get-session';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { getEnvelopeById } from '@documenso/lib/server-only/envelope/get-envelope-by-id';
import { getTeamByUrl } from '@documenso/lib/server-only/team/get-team';
import { Button } from '@documenso/ui/primitives/button';
import { GenericErrorLayout } from '~/components/general/generic-error-layout';
import type { Route } from './+types/settings._layout';
/**
* This file is very similar for templates as well. Any changes here should also be adjusted there as well.
*
* File: apps/remix/app/routes/_authenticated+/t.$teamUrl+/templates.$id._layout.tsx
*/
export async function loader({ request, params }: Route.LoaderArgs) {
const { id } = params;
const documentId = Number(id);
// If ID is a number, redirect to use envelope ID instead.
if (!Number.isNaN(documentId)) {
const { user } = await getSession(request);
const team = await getTeamByUrl({
userId: user.id,
teamUrl: params.teamUrl,
});
const envelope = await getEnvelopeById({
id: {
type: 'documentId',
id: documentId,
},
type: EnvelopeType.DOCUMENT,
userId: user.id,
teamId: team.id,
}).catch((err) => {
const error = AppError.parseError(err);
if (error.code === AppErrorCode.NOT_FOUND) {
throw new Response('Not Found', { status: 404 });
}
throw err;
});
const url = new URL(request.url);
throw redirect(url.pathname.replace(`/documents/${id}`, `/documents/${envelope.id}`));
}
}
export default function DocumentsLayout() {
return <Outlet />;
}
export function ErrorBoundary({ error, params }: Route.ErrorBoundaryProps) {
const errorCode = isRouteErrorResponse(error) ? error.status : 500;
const errorCodeMap = {
404: {
subHeading: msg`404 Document not found`,
heading: msg`Oops! Something went wrong.`,
message: msg`The document you are looking for could not be found.`,
},
};
return (
<GenericErrorLayout
errorCode={errorCode}
errorCodeMap={errorCodeMap}
secondaryButton={null}
primaryButton={
<Button asChild className="w-32">
<Link to={`/t/${params.teamUrl}/documents`}>
<ChevronLeftIcon className="mr-2 h-4 w-4" />
<Trans>Go Back</Trans>
</Link>
</Button>
}
/>
);
}

View File

@ -1,157 +1,107 @@
import { Plural, Trans } from '@lingui/react/macro';
import { TeamMemberRole } from '@prisma/client';
import { ChevronLeft, Users2 } from 'lucide-react';
import { Link, redirect } from 'react-router';
import { match } from 'ts-pattern';
import { useEffect } from 'react';
import { getSession } from '@documenso/auth/server/lib/utils/get-session';
import { getDocumentWithDetailsById } from '@documenso/lib/server-only/document/get-document-with-details-by-id';
import { getTeamByUrl } from '@documenso/lib/server-only/team/get-team';
import { DocumentVisibility } from '@documenso/lib/types/document-visibility';
import { isDocumentCompleted } from '@documenso/lib/utils/document';
import { logDocumentAccess } from '@documenso/lib/utils/logger';
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
import { msg } from '@lingui/core/macro';
import { Trans } from '@lingui/react/macro';
import { EnvelopeType } from '@prisma/client';
import { Link, useNavigate } from 'react-router';
import { DocumentEditForm } from '~/components/general/document/document-edit-form';
import { DocumentStatus } from '~/components/general/document/document-status';
import { LegacyFieldWarningPopover } from '~/components/general/legacy-field-warning-popover';
import { StackAvatarsWithTooltip } from '~/components/general/stack-avatars-with-tooltip';
import { superLoaderJson, useSuperLoaderData } from '~/utils/super-json-loader';
import { EnvelopeEditorProvider } from '@documenso/lib/client-only/providers/envelope-editor-provider';
import { EnvelopeRenderProvider } from '@documenso/lib/client-only/providers/envelope-render-provider';
import { formatDocumentsPath, formatTemplatesPath } from '@documenso/lib/utils/teams';
import { trpc } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
import { Spinner } from '@documenso/ui/primitives/spinner';
import EnvelopeEditor from '~/components/general/envelope-editor/envelope-editor';
import { GenericErrorLayout } from '~/components/general/generic-error-layout';
import { useCurrentTeam } from '~/providers/team';
import type { Route } from './+types/documents.$id.edit';
export async function loader({ params, request }: Route.LoaderArgs) {
const { user } = await getSession(request);
export default function EnvelopeEditorPage({ params }: Route.ComponentProps) {
const navigate = useNavigate();
const team = useCurrentTeam();
const teamUrl = params.teamUrl;
if (!teamUrl) {
throw new Response('Not Found', { status: 404 });
}
const team = await getTeamByUrl({ userId: user.id, teamUrl });
const { id } = params;
const documentId = Number(id);
const documentRootPath = formatDocumentsPath(team.url);
if (!documentId || Number.isNaN(documentId)) {
throw redirect(documentRootPath);
}
const document = await getDocumentWithDetailsById({
documentId,
userId: user.id,
teamId: team.id,
}).catch(() => null);
if (document?.teamId && !team?.url) {
throw redirect(documentRootPath);
}
const documentVisibility = document?.visibility;
const currentTeamMemberRole = team.currentTeamRole;
const isRecipient = document?.recipients.find((recipient) => recipient.email === user.email);
let canAccessDocument = true;
if (!isRecipient && document?.userId !== user.id) {
canAccessDocument = match([documentVisibility, currentTeamMemberRole])
.with([DocumentVisibility.EVERYONE, TeamMemberRole.ADMIN], () => true)
.with([DocumentVisibility.EVERYONE, TeamMemberRole.MANAGER], () => true)
.with([DocumentVisibility.EVERYONE, TeamMemberRole.MEMBER], () => true)
.with([DocumentVisibility.MANAGER_AND_ABOVE, TeamMemberRole.ADMIN], () => true)
.with([DocumentVisibility.MANAGER_AND_ABOVE, TeamMemberRole.MANAGER], () => true)
.with([DocumentVisibility.ADMIN, TeamMemberRole.ADMIN], () => true)
.otherwise(() => false);
}
if (!document) {
throw redirect(documentRootPath);
}
if (team && !canAccessDocument) {
throw redirect(documentRootPath);
}
if (isDocumentCompleted(document.status)) {
throw redirect(`${documentRootPath}/${documentId}`);
}
logDocumentAccess({
request,
documentId,
userId: user.id,
});
return superLoaderJson({
document: {
...document,
folder: null,
const {
data: envelope,
isLoading: isLoadingEnvelope,
isError: isErrorEnvelope,
} = trpc.envelope.get.useQuery(
{
envelopeId: params.id,
},
documentRootPath,
});
}
{
retry: false,
},
);
export default function DocumentEditPage() {
const { document, documentRootPath } = useSuperLoaderData<typeof loader>();
/**
* Need to handle redirecting to legacy editor on the client side to reduce server
* requests for the majority use case.
*/
useEffect(() => {
if (!envelope) {
return;
}
const { recipients } = document;
const pathPrefix =
envelope.type === EnvelopeType.DOCUMENT
? formatDocumentsPath(team.url)
: formatTemplatesPath(team.url);
if (envelope.teamId !== team.id) {
void navigate(pathPrefix, { replace: true });
} else if (envelope.internalVersion !== 2) {
void navigate(`${pathPrefix}/${envelope.id}/legacy_editor`, { replace: true });
}
}, [envelope, team, navigate]);
if (envelope && (envelope.teamId !== team.id || envelope.internalVersion !== 2)) {
return (
<div className="text-foreground flex h-screen w-screen flex-col items-center justify-center gap-2">
<Spinner />
<Trans>Redirecting</Trans>
</div>
);
}
if (isLoadingEnvelope) {
return (
<div className="text-foreground flex h-screen w-screen flex-col items-center justify-center gap-2">
<Spinner />
<Trans>Loading</Trans>
</div>
);
}
if (isErrorEnvelope || !envelope) {
return (
<GenericErrorLayout
errorCode={404}
errorCodeMap={{
404: {
heading: msg`Not found`,
subHeading: msg`404 Not found`,
message: msg`The document you are looking for may have been removed, renamed or may have never
existed.`,
},
}}
primaryButton={
<Button asChild>
<Link to={`/t/${team.url}/documents`}>
<Trans>Go home</Trans>
</Link>
</Button>
}
/>
);
}
return (
<div className="mx-auto -mt-4 w-full max-w-screen-xl px-4 md:px-8">
<Link to={documentRootPath} className="flex items-center text-[#7AC455] hover:opacity-80">
<ChevronLeft className="mr-2 inline-block h-5 w-5" />
<Trans>Documents</Trans>
</Link>
<div className="mt-4 flex w-full items-end justify-between">
<div className="flex-1">
<h1
className="block max-w-[20rem] truncate text-2xl font-semibold md:max-w-[30rem] md:text-3xl"
title={document.title}
>
{document.title}
</h1>
<div className="mt-2.5 flex items-center gap-x-6">
<DocumentStatus
inheritColor
status={document.status}
className="text-muted-foreground"
/>
{recipients.length > 0 && (
<div className="text-muted-foreground flex items-center">
<Users2 className="mr-2 h-5 w-5" />
<StackAvatarsWithTooltip
recipients={recipients}
documentStatus={document.status}
position="bottom"
>
<span>
<Plural one="1 Recipient" other="# Recipients" value={recipients.length} />
</span>
</StackAvatarsWithTooltip>
</div>
)}
</div>
</div>
{document.useLegacyFieldInsertion && (
<div>
<LegacyFieldWarningPopover type="document" documentId={document.id} />
</div>
)}
</div>
<DocumentEditForm
className="mt-6"
initialDocument={document}
documentRootPath={documentRootPath}
/>
</div>
<EnvelopeEditorProvider initialEnvelope={envelope}>
<EnvelopeRenderProvider envelope={envelope}>
<EnvelopeEditor />
</EnvelopeRenderProvider>
</EnvelopeEditorProvider>
);
}

View File

@ -0,0 +1,139 @@
import { Plural, Trans } from '@lingui/react/macro';
import { ChevronLeft, Users2 } from 'lucide-react';
import { Link, redirect } from 'react-router';
import { getSession } from '@documenso/auth/server/lib/utils/get-session';
import { getDocumentWithDetailsById } from '@documenso/lib/server-only/document/get-document-with-details-by-id';
import { getTeamByUrl } from '@documenso/lib/server-only/team/get-team';
import { isDocumentCompleted } from '@documenso/lib/utils/document';
import { logDocumentAccess } from '@documenso/lib/utils/logger';
import { canAccessTeamDocument, formatDocumentsPath } from '@documenso/lib/utils/teams';
import { DocumentEditForm } from '~/components/general/document/document-edit-form';
import { DocumentStatus } from '~/components/general/document/document-status';
import { LegacyFieldWarningPopover } from '~/components/general/legacy-field-warning-popover';
import { StackAvatarsWithTooltip } from '~/components/general/stack-avatars-with-tooltip';
import { superLoaderJson, useSuperLoaderData } from '~/utils/super-json-loader';
import type { Route } from './+types/documents.$id.edit';
export async function loader({ params, request }: Route.LoaderArgs) {
const { id, teamUrl } = params;
if (!id || !teamUrl) {
throw new Response('Not Found', { status: 404 });
}
const { user } = await getSession(request);
const team = await getTeamByUrl({ userId: user.id, teamUrl });
const documentRootPath = formatDocumentsPath(team.url);
const document = await getDocumentWithDetailsById({
id: {
type: 'envelopeId',
id,
},
userId: user.id,
teamId: team.id,
}).catch(() => null);
if (!document) {
throw new Response('Not Found', { status: 404 });
}
const documentVisibility = document.visibility;
const currentTeamMemberRole = team.currentTeamRole;
const isRecipient = document.recipients.find((recipient) => recipient.email === user.email);
let canAccessDocument = true;
if (!isRecipient && document.userId !== user.id) {
canAccessDocument = canAccessTeamDocument(currentTeamMemberRole, documentVisibility);
}
if (!canAccessDocument) {
throw new Response('Not Found', { status: 404 });
}
if (isDocumentCompleted(document.status)) {
throw redirect(`${documentRootPath}/${id}`);
}
logDocumentAccess({
request,
documentId: document.id,
userId: user.id,
});
return superLoaderJson({
document: {
...document,
folder: null,
},
documentRootPath,
});
}
export default function DocumentEditPage() {
const { document, documentRootPath } = useSuperLoaderData<typeof loader>();
const { recipients } = document;
return (
<div className="mx-auto -mt-4 w-full max-w-screen-xl px-4 md:px-8">
<Link to={documentRootPath} className="flex items-center text-[#7AC455] hover:opacity-80">
<ChevronLeft className="mr-2 inline-block h-5 w-5" />
<Trans>Documents</Trans>
</Link>
<div className="mt-4 flex w-full items-end justify-between">
<div className="flex-1">
<h1
className="block max-w-[20rem] truncate text-2xl font-semibold md:max-w-[30rem] md:text-3xl"
title={document.title}
>
{document.title}
</h1>
<div className="mt-2.5 flex items-center gap-x-6">
<DocumentStatus
inheritColor
status={document.status}
className="text-muted-foreground"
/>
{recipients.length > 0 && (
<div className="text-muted-foreground flex items-center">
<Users2 className="mr-2 h-5 w-5" />
<StackAvatarsWithTooltip
recipients={recipients}
documentStatus={document.status}
position="bottom"
>
<span>
<Plural one="1 Recipient" other="# Recipients" value={recipients.length} />
</span>
</StackAvatarsWithTooltip>
</div>
)}
</div>
</div>
{document.useLegacyFieldInsertion && (
<div>
<LegacyFieldWarningPopover type="document" documentId={document.id} />
</div>
)}
</div>
<DocumentEditForm
className="mt-6"
initialDocument={document}
documentRootPath={documentRootPath}
/>
</div>
);
}

View File

@ -2,15 +2,15 @@ import type { MessageDescriptor } from '@lingui/core';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import type { Recipient } from '@prisma/client';
import { EnvelopeType, type Recipient } from '@prisma/client';
import { ChevronLeft } from 'lucide-react';
import { DateTime } from 'luxon';
import { Link, redirect } from 'react-router';
import { Link } from 'react-router';
import { getSession } from '@documenso/auth/server/lib/utils/get-session';
import { getDocumentById } from '@documenso/lib/server-only/document/get-document-by-id';
import { getRecipientsForDocument } from '@documenso/lib/server-only/recipient/get-recipients-for-document';
import { getEnvelopeById } from '@documenso/lib/server-only/envelope/get-envelope-by-id';
import { getTeamByUrl } from '@documenso/lib/server-only/team/get-team';
import { mapSecondaryIdToDocumentId } from '@documenso/lib/utils/envelope';
import { logDocumentAccess } from '@documenso/lib/utils/logger';
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
import { Card } from '@documenso/ui/primitives/card';
@ -26,45 +26,54 @@ import { DocumentLogsTable } from '~/components/tables/document-logs-table';
import type { Route } from './+types/documents.$id.logs';
export async function loader({ params, request }: Route.LoaderArgs) {
const { id, teamUrl } = params;
if (!id || !teamUrl) {
throw new Response('Not Found', { status: 404 });
}
const { user } = await getSession(request);
const team = await getTeamByUrl({ userId: user.id, teamUrl: params.teamUrl });
const { id } = params;
const documentId = Number(id);
const team = await getTeamByUrl({ userId: user.id, teamUrl });
const documentRootPath = formatDocumentsPath(team.url);
if (!documentId || Number.isNaN(documentId)) {
throw redirect(documentRootPath);
}
const document = await getDocumentById({
documentId,
const envelope = await getEnvelopeById({
id: {
type: 'envelopeId',
id,
},
type: EnvelopeType.DOCUMENT,
userId: user.id,
teamId: team?.id,
teamId: team.id,
}).catch(() => null);
if (!document || !document.documentData) {
throw redirect(documentRootPath);
if (!envelope) {
throw new Response('Not Found', { status: 404 });
}
const recipients = await getRecipientsForDocument({
documentId,
userId: user.id,
teamId: team?.id,
});
logDocumentAccess({
request,
documentId,
documentId: mapSecondaryIdToDocumentId(envelope.secondaryId),
userId: user.id,
});
return {
document,
recipients,
// Only return necessary data
document: {
id: mapSecondaryIdToDocumentId(envelope.secondaryId),
envelopeId: envelope.id,
title: envelope.title,
status: envelope.status,
user: {
name: envelope.user.name,
email: envelope.user.email,
},
createdAt: envelope.createdAt,
updatedAt: envelope.updatedAt,
documentMeta: envelope.documentMeta,
},
recipients: envelope.recipients,
documentRootPath,
};
}
@ -123,7 +132,7 @@ export default function DocumentsLogsPage({ loaderData }: Route.ComponentProps)
return (
<div className="mx-auto -mt-4 w-full max-w-screen-xl px-4 md:px-8">
<Link
to={`${documentRootPath}/${document.id}`}
to={`${documentRootPath}/${document.envelopeId}`}
className="flex items-center text-[#7AC455] hover:opacity-80"
>
<ChevronLeft className="mr-2 inline-block h-5 w-5" />

View File

@ -108,6 +108,7 @@ export default function DocumentsPage() {
}
}, [data?.stats]);
// Todo: Envelopes - Change the dropzone wrapper to create to V2 documents after we're ready.
return (
<DocumentDropZoneWrapper>
<div className="mx-auto w-full max-w-screen-xl px-4 md:px-8">

View File

@ -2,8 +2,7 @@ import { useEffect, useMemo, useState } from 'react';
import { useLingui } from '@lingui/react/macro';
import { Trans } from '@lingui/react/macro';
import type { TemplateDirectLink } from '@prisma/client';
import { TemplateType } from '@prisma/client';
import { type TemplateDirectLink, TemplateType } from '@prisma/client';
import { getSession } from '@documenso/auth/server/lib/utils/get-session';
import { useSession } from '@documenso/lib/client-only/providers/session';

View File

@ -1,20 +1,27 @@
import { Trans } from '@lingui/react/macro';
import { msg } from '@lingui/core/macro';
import { Trans, useLingui } from '@lingui/react/macro';
import { DocumentSigningOrder, SigningStatus } from '@prisma/client';
import { ChevronLeft, LucideEdit } from 'lucide-react';
import { Link, redirect, useNavigate } from 'react-router';
import { Link, useNavigate } from 'react-router';
import { getSession } from '@documenso/auth/server/lib/utils/get-session';
import { getTeamByUrl } from '@documenso/lib/server-only/team/get-team';
import { getTemplateById } from '@documenso/lib/server-only/template/get-template-by-id';
import { EnvelopeRenderProvider } from '@documenso/lib/client-only/providers/envelope-render-provider';
import { useSession } from '@documenso/lib/client-only/providers/session';
import { mapSecondaryIdToTemplateId } from '@documenso/lib/utils/envelope';
import { formatDocumentsPath, formatTemplatesPath } from '@documenso/lib/utils/teams';
import { trpc } from '@documenso/trpc/react';
import { DocumentReadOnlyFields } from '@documenso/ui/components/document/document-read-only-fields';
import PDFViewerKonvaLazy from '@documenso/ui/components/pdf-viewer/pdf-viewer-konva-lazy';
import { Button } from '@documenso/ui/primitives/button';
import { Card, CardContent } from '@documenso/ui/primitives/card';
import { PDFViewer } from '@documenso/ui/primitives/pdf-viewer';
import { Spinner } from '@documenso/ui/primitives/spinner';
import { TemplateBulkSendDialog } from '~/components/dialogs/template-bulk-send-dialog';
import { TemplateDirectLinkDialogWrapper } from '~/components/dialogs/template-direct-link-dialog-wrapper';
import { TemplateDirectLinkDialog } from '~/components/dialogs/template-direct-link-dialog';
import { TemplateUseDialog } from '~/components/dialogs/template-use-dialog';
import { EnvelopeRendererFileSelector } from '~/components/general/envelope-editor/envelope-file-selector';
import EnvelopeGenericPageRenderer from '~/components/general/envelope-editor/envelope-generic-page-renderer';
import { GenericErrorLayout } from '~/components/general/generic-error-layout';
import { TemplateDirectLinkBadge } from '~/components/general/template/template-direct-link-badge';
import { TemplatePageViewDocumentsTable } from '~/components/general/template/template-page-view-documents-table';
import { TemplatePageViewInformation } from '~/components/general/template/template-page-view-information';
@ -22,55 +29,65 @@ import { TemplatePageViewRecentActivity } from '~/components/general/template/te
import { TemplatePageViewRecipients } from '~/components/general/template/template-page-view-recipients';
import { TemplateType } from '~/components/general/template/template-type';
import { TemplatesTableActionDropdown } from '~/components/tables/templates-table-action-dropdown';
import { superLoaderJson, useSuperLoaderData } from '~/utils/super-json-loader';
import { useCurrentTeam } from '~/providers/team';
import type { Route } from './+types/templates.$id._index';
export async function loader({ params, request }: Route.LoaderArgs) {
const { user } = await getSession(request);
const team = await getTeamByUrl({ userId: user.id, teamUrl: params.teamUrl });
const { id } = params;
const templateId = Number(id);
const templateRootPath = formatTemplatesPath(team.url);
const documentRootPath = formatDocumentsPath(team.url);
if (!templateId || Number.isNaN(templateId)) {
throw redirect(templateRootPath);
}
const template = await getTemplateById({
id: templateId,
userId: user.id,
teamId: team?.id,
}).catch(() => null);
if (!template || !template.templateDocumentData || (template?.teamId && !team.url)) {
throw redirect(templateRootPath);
}
return superLoaderJson({
user,
team,
template,
templateRootPath,
documentRootPath,
});
}
export default function TemplatePage() {
const { user, team, template, templateRootPath, documentRootPath } =
useSuperLoaderData<typeof loader>();
const { templateDocumentData, fields, recipients, templateMeta } = template;
export default function TemplatePage({ params }: Route.ComponentProps) {
const { t } = useLingui();
const { user } = useSession();
const navigate = useNavigate();
const team = useCurrentTeam();
const {
data: envelope,
isLoading: isLoadingEnvelope,
isError: isErrorEnvelope,
} = trpc.envelope.get.useQuery({
envelopeId: params.id,
});
if (isLoadingEnvelope) {
return (
<div className="text-foreground flex w-screen flex-col items-center justify-center gap-2 py-64">
<Spinner />
<Trans>Loading</Trans>
</div>
);
}
if (isErrorEnvelope || !envelope) {
return (
<GenericErrorLayout
errorCode={404}
errorCodeMap={{
404: {
heading: msg`Not found`,
subHeading: msg`404 Not found`,
message: msg`The template you are looking for may have been removed, renamed or may have never
existed.`,
},
}}
primaryButton={
<Button asChild>
<Link to={`/t/${team.url}/templates`}>
<Trans>Go back</Trans>
</Link>
</Button>
}
/>
);
}
const documentRootPath = formatDocumentsPath(team.url);
const templateRootPath = formatTemplatesPath(team.url);
// Remap to fit the DocumentReadOnlyFields component.
const readOnlyFields = fields.map((field) => {
const recipient = recipients.find((recipient) => recipient.id === field.recipientId) || {
const readOnlyFields = envelope.fields.map((field) => {
const recipient = envelope.recipients.find(
(recipient) => recipient.id === field.recipientId,
) || {
name: '',
email: '',
signingStatus: SigningStatus.NOT_SIGNED,
@ -83,10 +100,10 @@ export default function TemplatePage() {
};
});
const mockedDocumentMeta = templateMeta
const mockedDocumentMeta = envelope.documentMeta
? {
...templateMeta,
signingOrder: templateMeta.signingOrder || DocumentSigningOrder.SEQUENTIAL,
...envelope.documentMeta,
signingOrder: envelope.documentMeta.signingOrder || DocumentSigningOrder.SEQUENTIAL,
documentId: 0,
}
: undefined;
@ -102,31 +119,42 @@ export default function TemplatePage() {
<div>
<h1
className="mt-4 block max-w-[20rem] truncate text-2xl font-semibold md:max-w-[30rem] md:text-3xl"
title={template.title}
title={envelope.title}
>
{template.title}
{envelope.title}
</h1>
<div className="mt-2.5 flex items-center">
<TemplateType inheritColor className="text-muted-foreground" type={template.type} />
<TemplateType
inheritColor
className="text-muted-foreground"
type={envelope.templateType}
/>
{template.directLink?.token && (
{envelope.directLink?.token && (
<TemplateDirectLinkBadge
className="ml-4"
token={template.directLink.token}
enabled={template.directLink.enabled}
token={envelope.directLink.token}
enabled={envelope.directLink.enabled}
/>
)}
</div>
</div>
<div className="mt-2 flex flex-row space-x-4 sm:mt-0 sm:self-end">
<TemplateDirectLinkDialogWrapper template={template} />
<TemplateDirectLinkDialog
templateId={mapSecondaryIdToTemplateId(envelope.secondaryId)}
directLink={envelope.directLink}
recipients={envelope.recipients}
/>
<TemplateBulkSendDialog templateId={template.id} recipients={template.recipients} />
<TemplateBulkSendDialog
templateId={mapSecondaryIdToTemplateId(envelope.secondaryId)}
recipients={envelope.recipients}
/>
<Button className="w-full" asChild>
<Link to={`${templateRootPath}/${template.id}/edit`}>
<Link to={`${templateRootPath}/${envelope.id}/edit`}>
<LucideEdit className="mr-1.5 h-3.5 w-3.5" />
<Trans>Edit Template</Trans>
</Link>
@ -140,19 +168,33 @@ export default function TemplatePage() {
gradient
>
<CardContent className="p-2">
<PDFViewer document={template} key={template.id} documentData={templateDocumentData} />
{envelope.internalVersion === 2 ? (
<EnvelopeRenderProvider envelope={envelope} fields={envelope.fields}>
<EnvelopeRendererFileSelector fields={envelope.fields} className="mb-4 p-0" />
<PDFViewerKonvaLazy customPageRenderer={EnvelopeGenericPageRenderer} />
</EnvelopeRenderProvider>
) : (
<>
<DocumentReadOnlyFields
fields={readOnlyFields}
showFieldStatus={false}
showRecipientTooltip={true}
showRecipientColors={true}
recipientIds={envelope.recipients.map((recipient) => recipient.id)}
documentMeta={mockedDocumentMeta}
/>
<PDFViewer
document={envelope}
key={envelope.envelopeItems[0].id}
documentData={envelope.envelopeItems[0].documentData}
/>
</>
)}
</CardContent>
</Card>
<DocumentReadOnlyFields
fields={readOnlyFields}
showFieldStatus={false}
showRecipientTooltip={true}
showRecipientColors={true}
recipientIds={recipients.map((recipient) => recipient.id)}
documentMeta={mockedDocumentMeta}
/>
<div className="col-span-12 lg:col-span-6 xl:col-span-5">
<div className="space-y-6">
<section className="border-border bg-widget flex flex-col rounded-xl border pb-4 pt-6">
@ -163,7 +205,11 @@ export default function TemplatePage() {
<div>
<TemplatesTableActionDropdown
row={template}
row={{
...envelope,
id: mapSecondaryIdToTemplateId(envelope.secondaryId),
envelopeId: envelope.id,
}}
teamId={team?.id}
templateRootPath={templateRootPath}
onDelete={async () => navigate(templateRootPath)}
@ -177,9 +223,9 @@ export default function TemplatePage() {
<div className="mt-4 border-t px-4 pt-4">
<TemplateUseDialog
templateId={template.id}
templateSigningOrder={template.templateMeta?.signingOrder}
recipients={template.recipients}
templateId={mapSecondaryIdToTemplateId(envelope.secondaryId)}
templateSigningOrder={envelope.documentMeta?.signingOrder}
recipients={envelope.recipients}
documentRootPath={documentRootPath}
trigger={
<Button className="w-full">
@ -191,15 +237,19 @@ export default function TemplatePage() {
</section>
{/* Template information section. */}
<TemplatePageViewInformation template={template} userId={user.id} />
<TemplatePageViewInformation template={envelope} userId={user.id} />
{/* Recipients section. */}
<TemplatePageViewRecipients template={template} templateRootPath={templateRootPath} />
<TemplatePageViewRecipients
recipients={envelope.recipients}
envelopeId={envelope.id}
templateRootPath={templateRootPath}
/>
{/* Recent activity section. */}
<TemplatePageViewRecentActivity
documentRootPath={documentRootPath}
templateId={template.id}
templateId={mapSecondaryIdToTemplateId(envelope.secondaryId)}
/>
</div>
</div>
@ -210,7 +260,9 @@ export default function TemplatePage() {
<Trans>Documents created from template</Trans>
</h1>
<TemplatePageViewDocumentsTable templateId={template.id} />
<TemplatePageViewDocumentsTable
templateId={mapSecondaryIdToTemplateId(envelope.secondaryId)}
/>
</div>
</div>
);

View File

@ -0,0 +1,90 @@
import { msg } from '@lingui/core/macro';
import { Trans } from '@lingui/react/macro';
import { EnvelopeType } from '@prisma/client';
import { ChevronLeftIcon } from 'lucide-react';
import { Link, Outlet, isRouteErrorResponse, redirect } from 'react-router';
import { getSession } from '@documenso/auth/server/lib/utils/get-session';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { getEnvelopeById } from '@documenso/lib/server-only/envelope/get-envelope-by-id';
import { getTeamByUrl } from '@documenso/lib/server-only/team/get-team';
import { Button } from '@documenso/ui/primitives/button';
import { GenericErrorLayout } from '~/components/general/generic-error-layout';
import type { Route } from './+types/settings._layout';
/**
* This file is very similar for documents as well. Any changes here should also be adjusted there as well.
*
* File: apps/remix/app/routes/_authenticated+/t.$teamUrl+/documents.$id._layout.tsx
*/
export async function loader({ request, params }: Route.LoaderArgs) {
const { id } = params;
const templateId = Number(id);
// If ID is a number, redirect to use envelope ID instead.
if (!Number.isNaN(templateId)) {
const { user } = await getSession(request);
const team = await getTeamByUrl({
userId: user.id,
teamUrl: params.teamUrl,
});
const envelope = await getEnvelopeById({
id: {
type: 'templateId',
id: templateId,
},
type: EnvelopeType.TEMPLATE,
userId: user.id,
teamId: team.id,
}).catch((err) => {
const error = AppError.parseError(err);
if (error.code === AppErrorCode.NOT_FOUND) {
throw new Response('Not Found', { status: 404 });
}
throw err;
});
const url = new URL(request.url);
throw redirect(url.pathname.replace(`/templates/${id}`, `/templates/${envelope.id}`));
}
}
export default function TemplatesLayout() {
return <Outlet />;
}
export function ErrorBoundary({ error, params }: Route.ErrorBoundaryProps) {
const errorCode = isRouteErrorResponse(error) ? error.status : 500;
const errorCodeMap = {
404: {
subHeading: msg`404 Template not found`,
heading: msg`Oops! Something went wrong.`,
message: msg`The template you are looking for could not be found.`,
},
};
return (
<GenericErrorLayout
errorCode={errorCode}
errorCodeMap={errorCodeMap}
secondaryButton={null}
primaryButton={
<Button asChild className="w-32">
<Link to={`/t/${params.teamUrl}/templates`}>
<ChevronLeftIcon className="mr-2 h-4 w-4" />
<Trans>Go Back</Trans>
</Link>
</Button>
}
/>
);
}

View File

@ -1,108 +1,3 @@
import { Trans } from '@lingui/react/macro';
import { ChevronLeft } from 'lucide-react';
import { Link, redirect } from 'react-router';
import EnvelopeEditorPage from './documents.$id.edit';
import { getSession } from '@documenso/auth/server/lib/utils/get-session';
import { type TGetTeamByUrlResponse, getTeamByUrl } from '@documenso/lib/server-only/team/get-team';
import { getTemplateById } from '@documenso/lib/server-only/template/get-template-by-id';
import { formatTemplatesPath } from '@documenso/lib/utils/teams';
import { TemplateDirectLinkDialogWrapper } from '~/components/dialogs/template-direct-link-dialog-wrapper';
import { LegacyFieldWarningPopover } from '~/components/general/legacy-field-warning-popover';
import { TemplateDirectLinkBadge } from '~/components/general/template/template-direct-link-badge';
import { TemplateEditForm } from '~/components/general/template/template-edit-form';
import { TemplateType } from '~/components/general/template/template-type';
import { superLoaderJson, useSuperLoaderData } from '~/utils/super-json-loader';
import type { Route } from './+types/templates.$id.edit';
export async function loader({ params, request }: Route.LoaderArgs) {
const { user } = await getSession(request);
const team: TGetTeamByUrlResponse = await getTeamByUrl({
userId: user.id,
teamUrl: params.teamUrl,
});
const { id } = params;
const templateId = Number(id);
const templateRootPath = formatTemplatesPath(team?.url);
if (!templateId || Number.isNaN(templateId)) {
throw redirect(templateRootPath);
}
const template = await getTemplateById({
id: templateId,
userId: user.id,
teamId: team?.id,
}).catch(() => null);
if (!template || !template.templateDocumentData) {
throw redirect(templateRootPath);
}
return superLoaderJson({
template: {
...template,
folder: null,
},
templateRootPath,
});
}
export default function TemplateEditPage() {
const { template, templateRootPath } = useSuperLoaderData<typeof loader>();
return (
<div className="mx-auto -mt-4 max-w-screen-xl px-4 md:px-8">
<div className="flex flex-col justify-between sm:flex-row">
<div>
<Link
to={`${templateRootPath}/${template.id}`}
className="flex items-center text-[#7AC455] hover:opacity-80"
>
<ChevronLeft className="mr-2 inline-block h-5 w-5" />
<Trans>Template</Trans>
</Link>
<h1
className="mt-4 block max-w-[20rem] truncate text-2xl font-semibold md:max-w-[30rem] md:text-3xl"
title={template.title}
>
{template.title}
</h1>
<div className="mt-2.5 flex items-center">
<TemplateType inheritColor className="text-muted-foreground" type={template.type} />
{template.directLink?.token && (
<TemplateDirectLinkBadge
className="ml-4"
token={template.directLink.token}
enabled={template.directLink.enabled}
/>
)}
</div>
</div>
<div className="mt-2 flex items-center gap-2 sm:mt-0 sm:self-end">
<TemplateDirectLinkDialogWrapper template={template} />
{template.useLegacyFieldInsertion && (
<div>
<LegacyFieldWarningPopover type="template" templateId={template.id} />
</div>
)}
</div>
</div>
<TemplateEditForm
className="mt-6"
initialTemplate={template}
templateRootPath={templateRootPath}
/>
</div>
);
}
export default EnvelopeEditorPage;

View File

@ -0,0 +1,111 @@
import { Trans } from '@lingui/react/macro';
import { ChevronLeft } from 'lucide-react';
import { Link, redirect } from 'react-router';
import { getSession } from '@documenso/auth/server/lib/utils/get-session';
import { getTeamByUrl } from '@documenso/lib/server-only/team/get-team';
import { getTemplateById } from '@documenso/lib/server-only/template/get-template-by-id';
import { formatTemplatesPath } from '@documenso/lib/utils/teams';
import { TemplateDirectLinkDialog } from '~/components/dialogs/template-direct-link-dialog';
import { LegacyFieldWarningPopover } from '~/components/general/legacy-field-warning-popover';
import { TemplateDirectLinkBadge } from '~/components/general/template/template-direct-link-badge';
import { TemplateEditForm } from '~/components/general/template/template-edit-form';
import { TemplateType } from '~/components/general/template/template-type';
import { superLoaderJson, useSuperLoaderData } from '~/utils/super-json-loader';
import type { Route } from './+types/templates.$id.edit';
export async function loader({ params, request }: Route.LoaderArgs) {
const { id, teamUrl } = params;
if (!id || !teamUrl) {
throw new Response('Not Found', { status: 404 });
}
const { user } = await getSession(request);
const team = await getTeamByUrl({ userId: user.id, teamUrl });
const templateRootPath = formatTemplatesPath(team.url);
const template = await getTemplateById({
id: {
type: 'envelopeId',
id,
},
userId: user.id,
teamId: team.id,
}).catch(() => null);
if (!template || !template.templateDocumentData) {
throw redirect(templateRootPath);
}
return superLoaderJson({
template: {
...template,
folder: null,
},
templateRootPath,
});
}
export default function TemplateEditPage() {
const { template, templateRootPath } = useSuperLoaderData<typeof loader>();
return (
<div className="mx-auto -mt-4 max-w-screen-xl px-4 md:px-8">
<div className="flex flex-col justify-between sm:flex-row">
<div>
<Link
to={`${templateRootPath}/${template.envelopeId}`}
className="flex items-center text-[#7AC455] hover:opacity-80"
>
<ChevronLeft className="mr-2 inline-block h-5 w-5" />
<Trans>Template</Trans>
</Link>
<h1
className="mt-4 block max-w-[20rem] truncate text-2xl font-semibold md:max-w-[30rem] md:text-3xl"
title={template.title}
>
{template.title}
</h1>
<div className="mt-2.5 flex items-center">
<TemplateType inheritColor className="text-muted-foreground" type={template.type} />
{template.directLink?.token && (
<TemplateDirectLinkBadge
className="ml-4"
token={template.directLink.token}
enabled={template.directLink.enabled}
/>
)}
</div>
</div>
<div className="mt-2 flex items-center gap-2 sm:mt-0 sm:self-end">
<TemplateDirectLinkDialog
templateId={template.id}
directLink={template.directLink}
recipients={template.recipients}
/>
{template.useLegacyFieldInsertion && (
<div>
<LegacyFieldWarningPopover type="template" templateId={template.id} />
</div>
)}
</div>
</div>
<TemplateEditForm
className="mt-6"
initialTemplate={template}
templateRootPath={templateRootPath}
/>
</div>
);
}

View File

@ -1,14 +1,16 @@
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { EnvelopeType } from '@prisma/client';
import { DateTime } from 'luxon';
import { redirect } from 'react-router';
import { DOCUMENT_STATUS } from '@documenso/lib/constants/document';
import { APP_I18N_OPTIONS, ZSupportedLanguageCodeSchema } from '@documenso/lib/constants/i18n';
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
import { getEntireDocument } from '@documenso/lib/server-only/admin/get-entire-document';
import { unsafeGetEntireEnvelope } from '@documenso/lib/server-only/admin/get-entire-document';
import { decryptSecondaryData } from '@documenso/lib/server-only/crypto/decrypt';
import { findDocumentAuditLogs } from '@documenso/lib/server-only/document/find-document-audit-logs';
import { mapSecondaryIdToDocumentId } from '@documenso/lib/utils/envelope';
import { getTranslations } from '@documenso/lib/utils/i18n';
import { Card, CardContent } from '@documenso/ui/primitives/card';
@ -39,20 +41,24 @@ export async function loader({ request }: Route.LoaderArgs) {
const documentId = Number(rawDocumentId);
const document = await getEntireDocument({
id: documentId,
const envelope = await unsafeGetEntireEnvelope({
id: {
type: 'documentId',
id: documentId,
},
type: EnvelopeType.DOCUMENT,
}).catch(() => null);
if (!document) {
if (!envelope) {
throw redirect('/');
}
const documentLanguage = ZSupportedLanguageCodeSchema.parse(document.documentMeta?.language);
const documentLanguage = ZSupportedLanguageCodeSchema.parse(envelope.documentMeta?.language);
const { data: auditLogs } = await findDocumentAuditLogs({
documentId: documentId,
userId: document.userId,
teamId: document.teamId,
userId: envelope.userId,
teamId: envelope.teamId,
perPage: 100_000,
});
@ -60,7 +66,21 @@ export async function loader({ request }: Route.LoaderArgs) {
return {
auditLogs,
document,
document: {
id: mapSecondaryIdToDocumentId(envelope.secondaryId),
title: envelope.title,
status: envelope.status,
envelopeId: envelope.id,
user: {
name: envelope.user.name,
email: envelope.user.email,
},
recipients: envelope.recipients,
createdAt: envelope.createdAt,
updatedAt: envelope.updatedAt,
deletedAt: envelope.deletedAt,
documentMeta: envelope.documentMeta,
},
documentLanguage,
messages,
};
@ -90,9 +110,9 @@ export default function AuditLog({ loaderData }: Route.ComponentProps) {
<Card>
<CardContent className="grid grid-cols-2 gap-4 p-6 text-sm print:text-xs">
<p>
<span className="font-medium">{_(msg`Document ID`)}</span>
<span className="font-medium">{_(msg`Envelope ID`)}</span>
<span className="mt-1 block break-words">{document.id}</span>
<span className="mt-1 block break-words">{document.envelopeId}</span>
</p>
<p>

View File

@ -1,6 +1,6 @@
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { FieldType, SigningStatus } from '@prisma/client';
import { EnvelopeType, FieldType, SigningStatus } from '@prisma/client';
import { DateTime } from 'luxon';
import { redirect } from 'react-router';
import { prop, sortBy } from 'remeda';
@ -14,12 +14,13 @@ import {
RECIPIENT_ROLES_DESCRIPTION,
RECIPIENT_ROLE_SIGNING_REASONS,
} from '@documenso/lib/constants/recipient-roles';
import { getEntireDocument } from '@documenso/lib/server-only/admin/get-entire-document';
import { unsafeGetEntireEnvelope } from '@documenso/lib/server-only/admin/get-entire-document';
import { decryptSecondaryData } from '@documenso/lib/server-only/crypto/decrypt';
import { getDocumentCertificateAuditLogs } from '@documenso/lib/server-only/document/get-document-certificate-audit-logs';
import { getOrganisationClaimByTeamId } from '@documenso/lib/server-only/organisation/get-organisation-claims';
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth';
import { mapSecondaryIdToDocumentId } from '@documenso/lib/utils/envelope';
import { getTranslations } from '@documenso/lib/utils/i18n';
import { Card, CardContent } from '@documenso/ui/primitives/card';
import {
@ -55,26 +56,45 @@ export async function loader({ request }: Route.LoaderArgs) {
const documentId = Number(rawDocumentId);
const document = await getEntireDocument({
id: documentId,
const envelope = await unsafeGetEntireEnvelope({
id: {
type: 'documentId',
id: documentId,
},
type: EnvelopeType.DOCUMENT,
}).catch(() => null);
if (!document) {
if (!envelope) {
throw redirect('/');
}
const organisationClaim = await getOrganisationClaimByTeamId({ teamId: document.teamId });
const organisationClaim = await getOrganisationClaimByTeamId({ teamId: envelope.teamId });
const documentLanguage = ZSupportedLanguageCodeSchema.parse(document.documentMeta?.language);
const documentLanguage = ZSupportedLanguageCodeSchema.parse(envelope.documentMeta?.language);
const auditLogs = await getDocumentCertificateAuditLogs({
id: documentId,
envelopeId: envelope.id,
});
const messages = await getTranslations(documentLanguage);
return {
document,
document: {
id: mapSecondaryIdToDocumentId(envelope.secondaryId),
title: envelope.title,
status: envelope.status,
user: {
name: envelope.user.name,
email: envelope.user.email,
},
qrToken: envelope.qrToken,
authOptions: envelope.authOptions,
recipients: envelope.recipients,
createdAt: envelope.createdAt,
updatedAt: envelope.updatedAt,
deletedAt: envelope.deletedAt,
documentMeta: envelope.documentMeta,
},
hidePoweredBy: organisationClaim.flags.hidePoweredBy,
documentLanguage,
auditLogs,

View File

@ -3,6 +3,7 @@ import { ChevronLeft } from 'lucide-react';
import { Link, Outlet, isRouteErrorResponse } from 'react-router';
import { useOptionalSession } from '@documenso/lib/client-only/providers/session';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import { Header as AuthenticatedHeader } from '~/components/general/app-header';
@ -16,14 +17,23 @@ import type { Route } from './+types/_layout';
*
* Such as direct template access, or signing.
*/
export default function RecipientLayout() {
export default function RecipientLayout({ matches }: Route.ComponentProps) {
const { sessionData } = useOptionalSession();
// Hide the header for signing routes.
const hideHeader = matches.some(
(match) => match?.id === 'routes/_recipient+/sign.$token+/_index',
);
return (
<div className="min-h-screen">
{sessionData?.user && <AuthenticatedHeader />}
{!hideHeader && sessionData?.user && <AuthenticatedHeader />}
<main className="mb-8 mt-8 px-4 md:mb-12 md:mt-12 md:px-8">
<main
className={cn({
'mb-8 mt-8 px-4 md:mb-12 md:mt-12 md:px-8': !hideHeader,
})}
>
<Outlet />
</main>
</div>

View File

@ -7,9 +7,13 @@ import { match } from 'ts-pattern';
import signingCelebration from '@documenso/assets/images/signing-celebration.png';
import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session';
import { EnvelopeRenderProvider } from '@documenso/lib/client-only/providers/envelope-render-provider';
import { useOptionalSession } from '@documenso/lib/client-only/providers/session';
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 { getIsRecipientsTurnToSign } from '@documenso/lib/server-only/recipient/get-is-recipient-turn';
@ -21,17 +25,21 @@ import { getTeamSettings } from '@documenso/lib/server-only/team/get-team-settin
import { getUserByEmail } from '@documenso/lib/server-only/user/get-user-by-email';
import { DocumentAccessAuth } from '@documenso/lib/types/document-auth';
import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth';
import { prisma } from '@documenso/prisma';
import { SigningCard3D } from '@documenso/ui/components/signing-card';
import { Header as AuthenticatedHeader } from '~/components/general/app-header';
import { DocumentSigningAuthPageView } from '~/components/general/document-signing/document-signing-auth-page';
import { DocumentSigningAuthProvider } from '~/components/general/document-signing/document-signing-auth-provider';
import { DocumentSigningPageView } from '~/components/general/document-signing/document-signing-page-view';
import { DocumentSigningPageViewV1 } from '~/components/general/document-signing/document-signing-page-view-v1';
import { DocumentSigningPageViewV2 } from '~/components/general/document-signing/document-signing-page-view-v2';
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 type { Route } from './+types/_index';
export async function loader({ params, request }: Route.LoaderArgs) {
const handleV1Loader = async ({ params, request }: Route.LoaderArgs) => {
const { requestMetadata } = getOptionalLoaderContext();
const { user } = await getOptionalSession(request);
@ -113,11 +121,11 @@ export async function loader({ params, request }: Route.LoaderArgs) {
.then((user) => !!user)
.catch(() => false);
return superLoaderJson({
return {
isDocumentAccessValid: false,
recipientEmail: recipient.email,
recipientHasAccount,
} as const);
} as const;
}
await viewedDocument({
@ -143,7 +151,7 @@ export async function loader({ params, request }: Route.LoaderArgs) {
const settings = await getTeamSettings({ teamId: document.teamId });
return superLoaderJson({
return {
isDocumentAccessValid: true,
document,
fields,
@ -154,13 +162,152 @@ export async function loader({ params, request }: Route.LoaderArgs) {
recipientSignature,
isRecipientsTurn,
includeSenderDetails: settings.includeSenderDetails,
} as const;
};
const handleV2Loader = async ({ params, request }: Route.LoaderArgs) => {
const { token } = params;
const { requestMetadata } = getOptionalLoaderContext();
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) {
return envelopeForSigning;
}
const { envelope, recipient, isCompleted, isRejected, isRecipientsTurn } = envelopeForSigning;
if (!isRecipientsTurn) {
throw redirect(`/sign/${token}/waiting`);
}
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) // Allow without account requirement
.exhaustive(),
);
let recipientHasAccount: boolean | null = null;
if (!isAccessAuthValid) {
recipientHasAccount = await getUserByEmail({ email: recipient.email })
.then((user) => !!user)
.catch(() => false);
return {
isDocumentAccessValid: false,
recipientEmail: recipient.email,
recipientHasAccount,
} as const;
}
await viewedDocument({
token,
requestMetadata,
recipientAccessAuth: derivedRecipientAccessAuth,
}).catch(() => null);
if (isRejected) {
throw redirect(`/sign/${token}/rejected`);
}
if (isCompleted) {
throw redirect(envelope.documentMeta.redirectUrl || `/sign/${token}/complete`);
}
return {
isDocumentAccessValid: true,
envelopeForSigning,
} as const;
};
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 SigningPage() {
const data = useSuperLoaderData<typeof loader>();
if (data.version === 2) {
return <SigningPageV2 data={data.payload} />;
}
return <SigningPageV1 data={data.payload} />;
}
const SigningPageV1 = ({ data }: { data: Awaited<ReturnType<typeof handleV1Loader>> }) => {
const { sessionData } = useOptionalSession();
const user = sessionData?.user;
if (!data.isDocumentAccessValid) {
@ -248,16 +395,107 @@ export default function SigningPage() {
recipient={recipient}
user={user}
>
<DocumentSigningPageView
recipient={recipientWithFields}
document={document}
fields={fields}
completedFields={completedFields}
isRecipientsTurn={isRecipientsTurn}
allRecipients={allRecipients}
includeSenderDetails={includeSenderDetails}
/>
<>
{sessionData?.user && <AuthenticatedHeader />}
<div className="mb-8 mt-8 px-4 md:mb-12 md:mt-12 md:px-8">
<DocumentSigningPageViewV1
recipient={recipientWithFields}
document={document}
fields={fields}
completedFields={completedFields}
isRecipientsTurn={isRecipientsTurn}
allRecipients={allRecipients}
includeSenderDetails={includeSenderDetails}
/>
</div>
</>
</DocumentSigningAuthProvider>
</DocumentSigningProvider>
);
}
};
const SigningPageV2 = ({ data }: { data: Awaited<ReturnType<typeof handleV2Loader>> }) => {
const { sessionData } = useOptionalSession();
const user = sessionData?.user;
if (!data.isDocumentAccessValid) {
return (
<DocumentSigningAuthPageView
email={data.recipientEmail}
emailHasAccount={!!data.recipientHasAccount}
/>
);
}
const { envelope, recipientSignature, recipient } = data.envelopeForSigning;
if (envelope.deletedAt || envelope.status === DocumentStatus.REJECTED) {
return (
<div className="-mx-4 flex max-w-[100vw] flex-col items-center overflow-x-hidden px-4 pt-16 md:-mx-8 md:px-8 lg:pt-16 xl:pt-24">
<SigningCard3D
name={recipient.name}
signature={recipientSignature || undefined}
signingCelebrationImage={signingCelebration}
/>
<div className="relative mt-2 flex w-full flex-col items-center">
<div className="mt-8 flex items-center text-center text-red-600">
<Clock8 className="mr-2 h-5 w-5" />
<span className="text-sm">
<Trans>Document Cancelled</Trans>
</span>
</div>
<h2 className="mt-6 max-w-[35ch] text-center text-2xl font-semibold leading-normal md:text-3xl lg:text-4xl">
<Trans>
<span className="mt-1.5 block">"{envelope.title}"</span>
is no longer available to sign
</Trans>
</h2>
<p className="text-muted-foreground/60 mt-2.5 max-w-[60ch] text-center text-sm font-medium md:text-base">
<Trans>This document has been cancelled by the owner.</Trans>
</p>
{user ? (
<Link to="/" className="text-documenso-700 hover:text-documenso-600 mt-36">
<Trans>Go Back Home</Trans>
</Link>
) : (
<p className="text-muted-foreground/60 mt-36 text-sm">
<Trans>
Want to send slick signing links like this one?{' '}
<Link
to="https://documenso.com"
className="text-documenso-700 hover:text-documenso-600"
>
Check out Documenso.
</Link>
</Trans>
</p>
)}
</div>
</div>
);
}
return (
<EnvelopeSigningProvider
envelopeData={data.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}>
<DocumentSigningPageViewV2 />
</EnvelopeRenderProvider>
</DocumentSigningAuthProvider>
</EnvelopeSigningProvider>
);
};

View File

@ -3,7 +3,7 @@ import { useEffect } from 'react';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { type Document, DocumentStatus, FieldType, RecipientRole } from '@prisma/client';
import { DocumentStatus, FieldType, RecipientRole } from '@prisma/client';
import { CheckCircle2, Clock8, FileSearch } from 'lucide-react';
import { Link, useRevalidator } from 'react-router';
import { match } from 'ts-pattern';
@ -19,6 +19,7 @@ import { getRecipientSignatures } from '@documenso/lib/server-only/recipient/get
import { getUserByEmail } from '@documenso/lib/server-only/user/get-user-by-email';
import { isDocumentCompleted } from '@documenso/lib/utils/document';
import { env } from '@documenso/lib/utils/env';
import type { Document } from '@documenso/prisma/types/document-legacy-schema';
import DocumentDialog from '@documenso/ui/components/document/document-dialog';
import { DocumentDownloadButton } from '@documenso/ui/components/document/document-download-button';
import { DocumentShareButton } from '@documenso/ui/components/document/document-share-button';

View File

@ -1,11 +1,11 @@
import { Trans } from '@lingui/react/macro';
import type { Team } from '@prisma/client';
import { DocumentStatus } from '@prisma/client';
import { DocumentStatus, EnvelopeType } from '@prisma/client';
import { Link, redirect } from 'react-router';
import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session';
import { getDocumentById } from '@documenso/lib/server-only/document/get-document-by-id';
import { getDocumentAndSenderByToken } from '@documenso/lib/server-only/document/get-document-by-token';
import { getEnvelopeById } from '@documenso/lib/server-only/envelope/get-envelope-by-id';
import { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-token';
import { getTeamById } from '@documenso/lib/server-only/team/get-team';
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
@ -40,12 +40,16 @@ export async function loader({ params, request }: Route.LoaderArgs) {
let team: Team | null = null;
if (user) {
isOwnerOrTeamMember = await getDocumentById({
documentId: document.id,
isOwnerOrTeamMember = await getEnvelopeById({
id: {
type: 'documentId',
id: document.id,
},
type: EnvelopeType.DOCUMENT,
userId: user.id,
teamId: document.teamId ?? undefined,
})
.then((document) => !!document)
.then((envelope) => !!envelope)
.catch(() => false);
if (document.teamId) {

View File

@ -81,9 +81,10 @@ export default function SharePage() {
<DocumentCertificateQRView
documentId={document.id}
title={document.title}
documentData={document.documentData}
password={document.documentMeta?.password}
recipientCount={document.recipients?.length ?? 0}
documentTeamUrl={document.documentTeamUrl}
internalVersion={document.internalVersion}
envelopeItems={document.envelopeItems}
recipientCount={document.recipientCount}
completedDate={document.completedAt ?? undefined}
/>
);

View File

@ -54,7 +54,10 @@ export const loader = async ({ request, params }: Route.LoaderArgs) => {
}
const document = await getDocumentWithDetailsById({
documentId,
id: {
type: 'documentId',
id: documentId,
},
userId: result?.userId,
teamId: result?.teamId ?? undefined,
}).catch(() => null);
@ -232,6 +235,7 @@ export default function EmbeddingAuthoringDocumentEditPage() {
.map<any>((f) => ({
...f,
id: f.nativeId,
envelopeItemId: document.documentData.envelopeItemId,
pageX: f.pageX,
pageY: f.pageY,
width: f.pageWidth,

View File

@ -54,7 +54,10 @@ export const loader = async ({ request, params }: Route.LoaderArgs) => {
}
const template = await getTemplateById({
id: templateId,
id: {
type: 'templateId',
id: templateId,
},
userId: result?.userId,
teamId: result?.teamId ?? undefined,
}).catch(() => null);
@ -227,11 +230,10 @@ export default function EmbeddingAuthoringTemplateEditPage() {
signingOrder: signer.signingOrder,
fields: fields
.filter((field) => field.signerEmail === signer.email)
// There's a gnarly discriminated union that makes this hard to satisfy, we're casting for the second
// eslint-disable-next-line @typescript-eslint/no-explicit-any
.map<any>((f) => ({
.map((f) => ({
...f,
id: f.nativeId,
envelopeItemId: template.templateDocumentData.envelopeItemId,
pageX: f.pageX,
pageY: f.pageY,
width: f.pageWidth,