Compare commits

..

8 Commits

Author SHA1 Message Date
17c6098638 v2.0.14 2025-11-20 15:12:40 +11:00
e5bde53ee4 chore: add translations (#2223) 2025-11-20 15:09:13 +11:00
0663605ffd fix: handle loading files in embedded authoring update flows (#2218) 2025-11-20 15:07:41 +11:00
1bbe561162 chore: add pending ui to signing completion page (#2224)
Adds a pending UI state to the signing completion page for when all
recipients have finished signing but the document hasn't completed the
sealing background job.

<img width="695" height="562" alt="image"
src="https://github.com/user-attachments/assets/b015bc38-9489-4baa-ac0a-07cb1ac24b25"
/>
2025-11-20 15:07:26 +11:00
fbc156722a feat: add Portuguese (Brazil) translation support version 2.0.6 (#2165)
Portuguese (Brazil) Translation Support for Documenso
2025-11-20 14:14:47 +11:00
f5d63fb76c feat: add option to change or disable OIDC login prompt parameter (#2037) 2025-11-20 13:08:36 +11:00
374477e692 refactor: improve layout of completed signing page (#2209) 2025-11-20 11:04:41 +11:00
11d9bde8f8 fix: improve sealing speed (#2210) 2025-11-19 14:15:12 +11:00
27 changed files with 11520 additions and 490 deletions

View File

@ -23,6 +23,10 @@ NEXT_PRIVATE_OIDC_CLIENT_ID=""
NEXT_PRIVATE_OIDC_CLIENT_SECRET="" NEXT_PRIVATE_OIDC_CLIENT_SECRET=""
NEXT_PRIVATE_OIDC_PROVIDER_LABEL="OIDC" NEXT_PRIVATE_OIDC_PROVIDER_LABEL="OIDC"
NEXT_PRIVATE_OIDC_SKIP_VERIFY="" NEXT_PRIVATE_OIDC_SKIP_VERIFY=""
# Specifies the prompt to use for OIDC signin, explicitly setting
# an empty string will omit the prompt parameter.
# See: https://www.cerberauth.com/blog/openid-connect-oauth2-prompts/
NEXT_PRIVATE_OIDC_PROMPT="login"
# [[URLS]] # [[URLS]]
NEXT_PUBLIC_WEBAPP_URL="http://localhost:3000" NEXT_PUBLIC_WEBAPP_URL="http://localhost:3000"

View File

@ -2,7 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useLingui } from '@lingui/react'; import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro'; import { Trans } from '@lingui/react/macro';
import type { DocumentData, FieldType } from '@prisma/client'; import type { EnvelopeItem, FieldType } from '@prisma/client';
import { ReadStatus, type Recipient, SendStatus, SigningStatus } from '@prisma/client'; import { ReadStatus, type Recipient, SendStatus, SigningStatus } from '@prisma/client';
import { base64 } from '@scure/base'; import { base64 } from '@scure/base';
import { ChevronsUpDown } from 'lucide-react'; import { ChevronsUpDown } from 'lucide-react';
@ -40,7 +40,8 @@ const DEFAULT_WIDTH_PX = MIN_WIDTH_PX * 2.5;
export type ConfigureFieldsViewProps = { export type ConfigureFieldsViewProps = {
configData: TConfigureEmbedFormSchema; configData: TConfigureEmbedFormSchema;
documentData?: DocumentData; presignToken?: string | undefined;
envelopeItem?: Pick<EnvelopeItem, 'id' | 'envelopeId'>;
defaultValues?: Partial<TConfigureFieldsFormSchema>; defaultValues?: Partial<TConfigureFieldsFormSchema>;
onBack?: (data: TConfigureFieldsFormSchema) => void; onBack?: (data: TConfigureFieldsFormSchema) => void;
onSubmit: (data: TConfigureFieldsFormSchema) => void; onSubmit: (data: TConfigureFieldsFormSchema) => void;
@ -48,7 +49,8 @@ export type ConfigureFieldsViewProps = {
export const ConfigureFieldsView = ({ export const ConfigureFieldsView = ({
configData, configData,
documentData, presignToken,
envelopeItem,
defaultValues, defaultValues,
onBack, onBack,
onSubmit, onSubmit,
@ -82,17 +84,25 @@ export const ConfigureFieldsView = ({
}, []); }, []);
const normalizedDocumentData = useMemo(() => { const normalizedDocumentData = useMemo(() => {
if (documentData) { if (envelopeItem) {
return documentData.data; return undefined;
} }
if (!configData.documentData) { if (!configData.documentData) {
return null; return undefined;
} }
return base64.encode(configData.documentData.data); return base64.encode(configData.documentData.data);
}, [configData.documentData]); }, [configData.documentData]);
const normalizedEnvelopeItem = useMemo(() => {
if (envelopeItem) {
return envelopeItem;
}
return { id: '', envelopeId: '' };
}, [envelopeItem]);
const recipients = useMemo(() => { const recipients = useMemo(() => {
return configData.signers.map<Recipient>((signer, index) => ({ return configData.signers.map<Recipient>((signer, index) => ({
id: signer.nativeId || index, id: signer.nativeId || index,
@ -534,56 +544,50 @@ export const ConfigureFieldsView = ({
)} )}
<Form {...form}> <Form {...form}>
{normalizedDocumentData && ( <div>
<div> <PDFViewer
<PDFViewer presignToken={presignToken}
overrideData={normalizedDocumentData} overrideData={normalizedDocumentData}
envelopeItem={{ envelopeItem={normalizedEnvelopeItem}
id: '', token={undefined}
envelopeId: '', version="signed"
}} />
token={undefined}
version="signed"
/>
<ElementVisible <ElementVisible
target={`${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${highestPageNumber}"]`} target={`${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${highestPageNumber}"]`}
> >
{localFields.map((field, index) => { {localFields.map((field, index) => {
const recipientIndex = recipients.findIndex( const recipientIndex = recipients.findIndex((r) => r.id === field.recipientId);
(r) => r.id === field.recipientId,
);
return ( return (
<FieldItem <FieldItem
key={field.formId} key={field.formId}
field={field} field={field}
minHeight={MIN_HEIGHT_PX} minHeight={MIN_HEIGHT_PX}
minWidth={MIN_WIDTH_PX} minWidth={MIN_WIDTH_PX}
defaultHeight={DEFAULT_HEIGHT_PX} defaultHeight={DEFAULT_HEIGHT_PX}
defaultWidth={DEFAULT_WIDTH_PX} defaultWidth={DEFAULT_WIDTH_PX}
onResize={(node) => onFieldResize(node, index)} onResize={(node) => onFieldResize(node, index)}
onMove={(node) => onFieldMove(node, index)} onMove={(node) => onFieldMove(node, index)}
onRemove={() => remove(index)} onRemove={() => remove(index)}
onDuplicate={() => onFieldCopy(null, { duplicate: true })} onDuplicate={() => onFieldCopy(null, { duplicate: true })}
onDuplicateAllPages={() => onFieldCopy(null, { duplicateAll: true })} onDuplicateAllPages={() => onFieldCopy(null, { duplicateAll: true })}
onFocus={() => setLastActiveField(field)} onFocus={() => setLastActiveField(field)}
onBlur={() => setLastActiveField(null)} onBlur={() => setLastActiveField(null)}
onAdvancedSettings={() => { onAdvancedSettings={() => {
setCurrentField(field); setCurrentField(field);
setShowAdvancedSettings(true); setShowAdvancedSettings(true);
}} }}
recipientIndex={recipientIndex} recipientIndex={recipientIndex}
active={activeFieldId === field.formId} active={activeFieldId === field.formId}
onFieldActivate={() => setActiveFieldId(field.formId)} onFieldActivate={() => setActiveFieldId(field.formId)}
onFieldDeactivate={() => setActiveFieldId(null)} onFieldDeactivate={() => setActiveFieldId(null)}
disabled={selectedRecipient?.id !== field.recipientId} disabled={selectedRecipient?.id !== field.recipientId}
/> />
); );
})} })}
</ElementVisible> </ElementVisible>
</div> </div>
)}
</Form> </Form>
</div> </div>
</div> </div>

View File

@ -1,10 +1,8 @@
import { useEffect } from 'react';
import { useLingui } from '@lingui/react'; import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro'; import { Trans } from '@lingui/react/macro';
import { DocumentStatus, FieldType, RecipientRole } from '@prisma/client'; import { DocumentStatus, FieldType, RecipientRole } from '@prisma/client';
import { CheckCircle2, Clock8, DownloadIcon } from 'lucide-react'; import { CheckCircle2, Clock8, DownloadIcon, Loader2 } from 'lucide-react';
import { Link, useRevalidator } from 'react-router'; import { Link } from 'react-router';
import { match } from 'ts-pattern'; import { match } from 'ts-pattern';
import signingCelebration from '@documenso/assets/images/signing-celebration.png'; import signingCelebration from '@documenso/assets/images/signing-celebration.png';
@ -18,7 +16,7 @@ import { getRecipientSignatures } from '@documenso/lib/server-only/recipient/get
import { getUserByEmail } from '@documenso/lib/server-only/user/get-user-by-email'; import { getUserByEmail } from '@documenso/lib/server-only/user/get-user-by-email';
import { isDocumentCompleted } from '@documenso/lib/utils/document'; import { isDocumentCompleted } from '@documenso/lib/utils/document';
import { env } from '@documenso/lib/utils/env'; import { env } from '@documenso/lib/utils/env';
import type { Document } from '@documenso/prisma/types/document-legacy-schema'; import { trpc } from '@documenso/trpc/react';
import { DocumentShareButton } from '@documenso/ui/components/document/document-share-button'; import { DocumentShareButton } from '@documenso/ui/components/document/document-share-button';
import { SigningCard3D } from '@documenso/ui/components/signing-card'; import { SigningCard3D } from '@documenso/ui/components/signing-card';
import { cn } from '@documenso/ui/lib/utils'; import { cn } from '@documenso/ui/lib/utils';
@ -84,6 +82,13 @@ export async function loader({ params, request }: Route.LoaderArgs) {
const canSignUp = !isExistingUser && env('NEXT_PUBLIC_DISABLE_SIGNUP') !== 'true'; const canSignUp = !isExistingUser && env('NEXT_PUBLIC_DISABLE_SIGNUP') !== 'true';
const canRedirectToFolder =
user && document.userId === user.id && document.folderId && document.team?.url;
const returnToHomePath = canRedirectToFolder
? `/t/${document.team.url}/documents/f/${document.folderId}`
: '/';
return { return {
isDocumentAccessValid: true, isDocumentAccessValid: true,
canSignUp, canSignUp,
@ -92,6 +97,7 @@ export async function loader({ params, request }: Route.LoaderArgs) {
signatures, signatures,
document, document,
recipient, recipient,
returnToHomePath,
}; };
} }
@ -109,8 +115,27 @@ export default function CompletedSigningPage({ loaderData }: Route.ComponentProp
document, document,
recipient, recipient,
recipientEmail, recipientEmail,
returnToHomePath,
} = loaderData; } = loaderData;
// Poll signing status every few seconds
const { data: signingStatusData } = trpc.envelope.signingStatus.useQuery(
{
token: recipient?.token || '',
},
{
refetchInterval: 3000,
initialData: match(document?.status)
.with(DocumentStatus.COMPLETED, () => ({ status: 'COMPLETED' }) as const)
.with(DocumentStatus.REJECTED, () => ({ status: 'REJECTED' }) as const)
.with(DocumentStatus.PENDING, () => ({ status: 'PENDING' }) as const)
.otherwise(() => ({ status: 'PENDING' }) as const),
},
);
// Use signing status from query if available, otherwise fall back to document status
const signingStatus = signingStatusData?.status ?? 'PENDING';
if (!isDocumentAccessValid) { if (!isDocumentAccessValid) {
return <DocumentSigningAuthPageView email={recipientEmail} />; return <DocumentSigningAuthPageView email={recipientEmail} />;
} }
@ -118,7 +143,7 @@ export default function CompletedSigningPage({ loaderData }: Route.ComponentProp
return ( return (
<div <div
className={cn( className={cn(
'-mx-4 flex flex-col items-center overflow-hidden px-4 pt-24 md:-mx-8 md:px-8 lg:pt-36 xl:pt-44', '-mx-4 flex flex-col items-center overflow-hidden px-4 pt-16 md:-mx-8 md:px-8 lg:pt-20 xl:pt-28',
{ 'pt-0 lg:pt-0 xl:pt-0': canSignUp }, { 'pt-0 lg:pt-0 xl:pt-0': canSignUp },
)} )}
> >
@ -152,8 +177,8 @@ export default function CompletedSigningPage({ loaderData }: Route.ComponentProp
{recipient.role === RecipientRole.APPROVER && <Trans>Document Approved</Trans>} {recipient.role === RecipientRole.APPROVER && <Trans>Document Approved</Trans>}
</h2> </h2>
{match({ status: document.status, deletedAt: document.deletedAt }) {match({ status: signingStatus, deletedAt: document.deletedAt })
.with({ status: DocumentStatus.COMPLETED }, () => ( .with({ status: 'COMPLETED' }, () => (
<div className="text-documenso-700 mt-4 flex items-center text-center"> <div className="text-documenso-700 mt-4 flex items-center text-center">
<CheckCircle2 className="mr-2 h-5 w-5" /> <CheckCircle2 className="mr-2 h-5 w-5" />
<span className="text-sm"> <span className="text-sm">
@ -161,6 +186,14 @@ export default function CompletedSigningPage({ loaderData }: Route.ComponentProp
</span> </span>
</div> </div>
)) ))
.with({ status: 'PROCESSING' }, () => (
<div className="mt-4 flex items-center text-center text-orange-600">
<Loader2 className="mr-2 h-5 w-5 animate-spin" />
<span className="text-sm">
<Trans>Processing document</Trans>
</span>
</div>
))
.with({ deletedAt: null }, () => ( .with({ deletedAt: null }, () => (
<div className="mt-4 flex items-center text-center text-blue-600"> <div className="mt-4 flex items-center text-center text-blue-600">
<Clock8 className="mr-2 h-5 w-5" /> <Clock8 className="mr-2 h-5 w-5" />
@ -178,14 +211,22 @@ export default function CompletedSigningPage({ loaderData }: Route.ComponentProp
</div> </div>
))} ))}
{match({ status: document.status, deletedAt: document.deletedAt }) {match({ status: signingStatus, deletedAt: document.deletedAt })
.with({ status: DocumentStatus.COMPLETED }, () => ( .with({ status: 'COMPLETED' }, () => (
<p className="text-muted-foreground/60 mt-2.5 max-w-[60ch] text-center text-sm font-medium md:text-base"> <p className="text-muted-foreground/60 mt-2.5 max-w-[60ch] text-center text-sm font-medium md:text-base">
<Trans> <Trans>
Everyone has signed! You will receive an Email copy of the signed document. Everyone has signed! You will receive an Email copy of the signed document.
</Trans> </Trans>
</p> </p>
)) ))
.with({ status: 'PROCESSING' }, () => (
<p className="text-muted-foreground/60 mt-2.5 max-w-[60ch] text-center text-sm font-medium md:text-base">
<Trans>
All recipients have signed. The document is being processed and you will receive
an Email copy shortly.
</Trans>
</p>
))
.with({ deletedAt: null }, () => ( .with({ deletedAt: null }, () => (
<p className="text-muted-foreground/60 mt-2.5 max-w-[60ch] text-center text-sm font-medium md:text-base"> <p className="text-muted-foreground/60 mt-2.5 max-w-[60ch] text-center text-sm font-medium md:text-base">
<Trans> <Trans>
@ -202,23 +243,35 @@ export default function CompletedSigningPage({ loaderData }: Route.ComponentProp
</p> </p>
))} ))}
<div className="mt-8 flex w-full max-w-sm items-center justify-center gap-4"> <div className="mt-8 flex w-full max-w-xs flex-col items-stretch gap-4 md:w-auto md:max-w-none md:flex-row md:items-center">
<DocumentShareButton documentId={document.id} token={recipient.token} /> <DocumentShareButton
documentId={document.id}
token={recipient.token}
className="w-full max-w-none md:flex-1"
/>
{isDocumentCompleted(document.status) && ( {isDocumentCompleted(document) && (
<EnvelopeDownloadDialog <EnvelopeDownloadDialog
envelopeId={document.envelopeId} envelopeId={document.envelopeId}
envelopeStatus={document.status} envelopeStatus={document.status}
envelopeItems={document.envelopeItems} envelopeItems={document.envelopeItems}
token={recipient?.token} token={recipient?.token}
trigger={ trigger={
<Button type="button" variant="outline" className="flex-1"> <Button type="button" variant="outline" className="flex-1 md:flex-initial">
<DownloadIcon className="mr-2 h-5 w-5" /> <DownloadIcon className="mr-2 h-5 w-5" />
<Trans>Download</Trans> <Trans>Download</Trans>
</Button> </Button>
} }
/> />
)} )}
{user && (
<Button asChild>
<Link to={returnToHomePath}>
<Trans>Go Back Home</Trans>
</Link>
</Button>
)}
</div> </div>
</div> </div>
@ -238,41 +291,8 @@ export default function CompletedSigningPage({ loaderData }: Route.ComponentProp
<ClaimAccount defaultName={recipientName} defaultEmail={recipient.email} /> <ClaimAccount defaultName={recipientName} defaultEmail={recipient.email} />
</div> </div>
)} )}
{user && (
<Link to="/" className="text-documenso-700 hover:text-documenso-600 mt-2">
<Trans>Go Back Home</Trans>
</Link>
)}
</div> </div>
</div> </div>
<PollUntilDocumentCompleted document={document} />
</div> </div>
); );
} }
export type PollUntilDocumentCompletedProps = {
document: Pick<Document, 'id' | 'status' | 'deletedAt'>;
};
export const PollUntilDocumentCompleted = ({ document }: PollUntilDocumentCompletedProps) => {
const { revalidate } = useRevalidator();
useEffect(() => {
if (isDocumentCompleted(document.status)) {
return;
}
const interval = setInterval(() => {
if (window.document.hasFocus()) {
void revalidate();
}
}, 5000);
return () => clearInterval(interval);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [document.status]);
return <></>;
};

View File

@ -75,6 +75,7 @@ export const loader = async ({ request, params }: Route.LoaderArgs) => {
})); }));
return { return {
token,
document: { document: {
...document, ...document,
fields, fields,
@ -86,7 +87,7 @@ export default function EmbeddingAuthoringDocumentEditPage() {
const { _ } = useLingui(); const { _ } = useLingui();
const { toast } = useToast(); const { toast } = useToast();
const { document } = useLoaderData<typeof loader>(); const { document, token } = useLoaderData<typeof loader>();
const [hasFinishedInit, setHasFinishedInit] = useState(false); const [hasFinishedInit, setHasFinishedInit] = useState(false);
@ -321,7 +322,8 @@ export default function EmbeddingAuthoringDocumentEditPage() {
<ConfigureFieldsView <ConfigureFieldsView
configData={configuration!} configData={configuration!}
documentData={document.documentData} presignToken={token}
envelopeItem={document.envelopeItems[0]}
defaultValues={fields ?? undefined} defaultValues={fields ?? undefined}
onBack={canGoBack ? handleBackToConfig : undefined} onBack={canGoBack ? handleBackToConfig : undefined}
onSubmit={handleConfigureFieldsSubmit} onSubmit={handleConfigureFieldsSubmit}

View File

@ -75,6 +75,7 @@ export const loader = async ({ request, params }: Route.LoaderArgs) => {
})); }));
return { return {
token,
template: { template: {
...template, ...template,
fields, fields,
@ -86,7 +87,7 @@ export default function EmbeddingAuthoringTemplateEditPage() {
const { _ } = useLingui(); const { _ } = useLingui();
const { toast } = useToast(); const { toast } = useToast();
const { template } = useLoaderData<typeof loader>(); const { template, token } = useLoaderData<typeof loader>();
const [hasFinishedInit, setHasFinishedInit] = useState(false); const [hasFinishedInit, setHasFinishedInit] = useState(false);
@ -321,7 +322,8 @@ export default function EmbeddingAuthoringTemplateEditPage() {
<ConfigureFieldsView <ConfigureFieldsView
configData={configuration!} configData={configuration!}
documentData={template.templateDocumentData} presignToken={token}
envelopeItem={template.envelopeItems[0]}
defaultValues={fields ?? undefined} defaultValues={fields ?? undefined}
onBack={canGoBack ? handleBackToConfig : undefined} onBack={canGoBack ? handleBackToConfig : undefined}
onSubmit={handleConfigureFieldsSubmit} onSubmit={handleConfigureFieldsSubmit}

View File

@ -106,5 +106,5 @@
"vite-plugin-babel-macros": "^1.0.6", "vite-plugin-babel-macros": "^1.0.6",
"vite-tsconfig-paths": "^5.1.4" "vite-tsconfig-paths": "^5.1.4"
}, },
"version": "2.0.13" "version": "2.0.14"
} }

View File

@ -5,6 +5,7 @@ import { Hono } from 'hono';
import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session'; import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session';
import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT } from '@documenso/lib/constants/app'; import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT } from '@documenso/lib/constants/app';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { verifyEmbeddingPresignToken } from '@documenso/lib/server-only/embedding-presign/verify-embedding-presign-token';
import { getTeamById } from '@documenso/lib/server-only/team/get-team'; import { getTeamById } from '@documenso/lib/server-only/team/get-team';
import { putNormalizedPdfFileServerSide } from '@documenso/lib/universal/upload/put-file.server'; import { putNormalizedPdfFileServerSide } from '@documenso/lib/universal/upload/put-file.server';
import { getPresignPostUrl } from '@documenso/lib/universal/upload/server-actions'; import { getPresignPostUrl } from '@documenso/lib/universal/upload/server-actions';
@ -16,6 +17,7 @@ import {
type TGetPresignedPostUrlResponse, type TGetPresignedPostUrlResponse,
ZGetEnvelopeItemFileDownloadRequestParamsSchema, ZGetEnvelopeItemFileDownloadRequestParamsSchema,
ZGetEnvelopeItemFileRequestParamsSchema, ZGetEnvelopeItemFileRequestParamsSchema,
ZGetEnvelopeItemFileRequestQuerySchema,
ZGetEnvelopeItemFileTokenDownloadRequestParamsSchema, ZGetEnvelopeItemFileTokenDownloadRequestParamsSchema,
ZGetEnvelopeItemFileTokenRequestParamsSchema, ZGetEnvelopeItemFileTokenRequestParamsSchema,
ZGetPresignedPostUrlRequestSchema, ZGetPresignedPostUrlRequestSchema,
@ -68,12 +70,24 @@ export const filesRoute = new Hono<HonoEnv>()
.get( .get(
'/envelope/:envelopeId/envelopeItem/:envelopeItemId', '/envelope/:envelopeId/envelopeItem/:envelopeItemId',
sValidator('param', ZGetEnvelopeItemFileRequestParamsSchema), sValidator('param', ZGetEnvelopeItemFileRequestParamsSchema),
sValidator('query', ZGetEnvelopeItemFileRequestQuerySchema),
async (c) => { async (c) => {
const { envelopeId, envelopeItemId } = c.req.valid('param'); const { envelopeId, envelopeItemId } = c.req.valid('param');
const { token } = c.req.query();
const session = await getOptionalSession(c); const session = await getOptionalSession(c);
if (!session.user) { let userId = session.user?.id;
if (token) {
const presignToken = await verifyEmbeddingPresignToken({
token,
}).catch(() => undefined);
userId = presignToken?.userId;
}
if (!userId) {
return c.json({ error: 'Unauthorized' }, 401); return c.json({ error: 'Unauthorized' }, 401);
} }
@ -104,7 +118,7 @@ export const filesRoute = new Hono<HonoEnv>()
} }
const team = await getTeamById({ const team = await getTeamById({
userId: session.user.id, userId: userId,
teamId: envelope.teamId, teamId: envelope.teamId,
}).catch((error) => { }).catch((error) => {
console.error(error); console.error(error);

View File

@ -36,6 +36,14 @@ export type TGetEnvelopeItemFileRequestParams = z.infer<
typeof ZGetEnvelopeItemFileRequestParamsSchema typeof ZGetEnvelopeItemFileRequestParamsSchema
>; >;
export const ZGetEnvelopeItemFileRequestQuerySchema = z.object({
token: z.string().optional(),
});
export type TGetEnvelopeItemFileRequestQuery = z.infer<
typeof ZGetEnvelopeItemFileRequestQuerySchema
>;
export const ZGetEnvelopeItemFileTokenRequestParamsSchema = z.object({ export const ZGetEnvelopeItemFileTokenRequestParamsSchema = z.object({
token: z.string().min(1), token: z.string().min(1),
envelopeItemId: z.string().min(1), envelopeItemId: z.string().min(1),

6
package-lock.json generated
View File

@ -1,12 +1,12 @@
{ {
"name": "@documenso/root", "name": "@documenso/root",
"version": "2.0.13", "version": "2.0.14",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "@documenso/root", "name": "@documenso/root",
"version": "2.0.13", "version": "2.0.14",
"workspaces": [ "workspaces": [
"apps/*", "apps/*",
"packages/*" "packages/*"
@ -101,7 +101,7 @@
}, },
"apps/remix": { "apps/remix": {
"name": "@documenso/remix", "name": "@documenso/remix",
"version": "2.0.13", "version": "2.0.14",
"dependencies": { "dependencies": {
"@cantoo/pdf-lib": "^2.5.2", "@cantoo/pdf-lib": "^2.5.2",
"@documenso/api": "*", "@documenso/api": "*",

View File

@ -1,6 +1,6 @@
{ {
"private": true, "private": true,
"version": "2.0.13", "version": "2.0.14",
"scripts": { "scripts": {
"build": "turbo run build", "build": "turbo run build",
"dev": "turbo run dev --filter=@documenso/remix", "dev": "turbo run dev --filter=@documenso/remix",

View File

@ -27,13 +27,13 @@ type HandleOAuthAuthorizeUrlOptions = {
/** /**
* Optional prompt to pass to the authorization endpoint. * Optional prompt to pass to the authorization endpoint.
*/ */
prompt?: 'login' | 'consent' | 'select_account'; prompt?: 'none' | 'login' | 'consent' | 'select_account';
}; };
const oauthCookieMaxAge = 60 * 10; // 10 minutes. const oauthCookieMaxAge = 60 * 10; // 10 minutes.
export const handleOAuthAuthorizeUrl = async (options: HandleOAuthAuthorizeUrlOptions) => { export const handleOAuthAuthorizeUrl = async (options: HandleOAuthAuthorizeUrlOptions) => {
const { c, clientOptions, redirectPath, prompt = 'login' } = options; const { c, clientOptions, redirectPath } = options;
if (!clientOptions.clientId || !clientOptions.clientSecret) { if (!clientOptions.clientId || !clientOptions.clientSecret) {
throw new AppError(AppErrorCode.NOT_SETUP); throw new AppError(AppErrorCode.NOT_SETUP);
@ -63,7 +63,11 @@ export const handleOAuthAuthorizeUrl = async (options: HandleOAuthAuthorizeUrlOp
); );
// Pass the prompt to the authorization endpoint. // Pass the prompt to the authorization endpoint.
url.searchParams.append('prompt', prompt); if (process.env.NEXT_PRIVATE_OIDC_PROMPT !== '') {
const prompt = process.env.NEXT_PRIVATE_OIDC_PROMPT ?? 'login';
url.searchParams.append('prompt', prompt);
}
setCookie(c, `${clientOptions.id}_oauth_state`, state, { setCookie(c, `${clientOptions.id}_oauth_state`, state, {
...sessionCookieOptions, ...sessionCookieOptions,

View File

@ -7,6 +7,7 @@ export const SUPPORTED_LANGUAGE_CODES = [
'es', 'es',
'it', 'it',
'pl', 'pl',
'pt-BR',
'ja', 'ja',
'ko', 'ko',
'zh', 'zh',
@ -64,6 +65,10 @@ export const SUPPORTED_LANGUAGES: Record<string, SupportedLanguage> = {
short: 'pl', short: 'pl',
full: 'Polish', full: 'Polish',
}, },
'pt-BR': {
short: 'pt-BR',
full: 'Portuguese (Brazil)',
},
ja: { ja: {
short: 'ja', short: 'ja',
full: 'Japanese', full: 'Japanese',

View File

@ -25,7 +25,6 @@ import { signPdf } from '@documenso/signing';
import { AppError, AppErrorCode } from '../../../errors/app-error'; import { AppError, AppErrorCode } from '../../../errors/app-error';
import { sendCompletedEmail } from '../../../server-only/document/send-completed-email'; import { sendCompletedEmail } from '../../../server-only/document/send-completed-email';
import PostHogServerClient from '../../../server-only/feature-flags/get-post-hog-server-client';
import { getAuditLogsPdf } from '../../../server-only/htmltopdf/get-audit-logs-pdf'; import { getAuditLogsPdf } from '../../../server-only/htmltopdf/get-audit-logs-pdf';
import { getCertificatePdf } from '../../../server-only/htmltopdf/get-certificate-pdf'; import { getCertificatePdf } from '../../../server-only/htmltopdf/get-certificate-pdf';
import { addRejectionStampToPdf } from '../../../server-only/pdf/add-rejection-stamp-to-pdf'; import { addRejectionStampToPdf } from '../../../server-only/pdf/add-rejection-stamp-to-pdf';
@ -62,171 +61,120 @@ export const run = async ({
}) => { }) => {
const { documentId, sendEmail = true, isResealing = false, requestMetadata } = payload; const { documentId, sendEmail = true, isResealing = false, requestMetadata } = payload;
const envelope = await prisma.envelope.findFirstOrThrow({ const { envelopeId, envelopeStatus, isRejected } = await io.runTask('seal-document', async () => {
where: { const envelope = await prisma.envelope.findFirstOrThrow({
type: EnvelopeType.DOCUMENT, where: {
secondaryId: mapDocumentIdToSecondaryId(documentId), type: EnvelopeType.DOCUMENT,
}, secondaryId: mapDocumentIdToSecondaryId(documentId),
include: { },
documentMeta: true, include: {
recipients: true, documentMeta: true,
envelopeItems: { recipients: true,
include: { envelopeItems: {
documentData: true, include: {
field: { documentData: true,
include: { field: {
signature: true, include: {
signature: true,
},
}, },
}, },
}, },
}, },
},
});
if (envelope.envelopeItems.length === 0) {
throw new Error('At least one envelope item required');
}
const settings = await getTeamSettings({
userId: envelope.userId,
teamId: envelope.teamId,
});
const isComplete =
envelope.recipients.some((recipient) => recipient.signingStatus === SigningStatus.REJECTED) ||
envelope.recipients.every((recipient) => recipient.signingStatus === SigningStatus.SIGNED);
if (!isComplete) {
throw new AppError(AppErrorCode.UNKNOWN_ERROR, {
message: 'Document is not complete',
}); });
}
// Seems silly but we need to do this in case the job is re-ran if (envelope.envelopeItems.length === 0) {
// after it has already run through the update task further below. throw new Error('At least one envelope item required');
// eslint-disable-next-line @typescript-eslint/require-await }
const documentStatus = await io.runTask('get-document-status', async () => {
return envelope.status;
});
// This is the same case as above. const settings = await getTeamSettings({
let envelopeItems = await io.runTask( userId: envelope.userId,
'get-document-data-id', teamId: envelope.teamId,
// eslint-disable-next-line @typescript-eslint/require-await });
async () => {
// eslint-disable-next-line unused-imports/no-unused-vars
return envelope.envelopeItems.map(({ field, ...rest }) => ({
...rest,
}));
},
);
if (envelopeItems.length < 1) { const isComplete =
throw new Error(`Document ${envelope.id} has no envelope items`); envelope.recipients.some((recipient) => recipient.signingStatus === SigningStatus.REJECTED) ||
} envelope.recipients.every((recipient) => recipient.signingStatus === SigningStatus.SIGNED);
const recipients = await prisma.recipient.findMany({ if (!isComplete) {
where: { throw new AppError(AppErrorCode.UNKNOWN_ERROR, {
envelopeId: envelope.id, message: 'Document is not complete',
role: { });
not: RecipientRole.CC, }
},
},
});
// Determine if the document has been rejected by checking if any recipient has rejected it let envelopeItems = envelope.envelopeItems;
const rejectedRecipient = recipients.find(
(recipient) => recipient.signingStatus === SigningStatus.REJECTED,
);
const isRejected = Boolean(rejectedRecipient); if (envelopeItems.length < 1) {
throw new Error(`Document ${envelope.id} has no envelope items`);
}
// Get the rejection reason from the rejected recipient const recipients = await prisma.recipient.findMany({
const rejectionReason = rejectedRecipient?.rejectionReason ?? '';
const fields = await prisma.field.findMany({
where: {
envelopeId: envelope.id,
},
include: {
signature: true,
},
});
// Skip the field check if the document is rejected
if (!isRejected && fieldsContainUnsignedRequiredField(fields)) {
throw new Error(`Document ${envelope.id} has unsigned required fields`);
}
if (isResealing) {
// If we're resealing we want to use the initial data for the document
// so we aren't placing fields on top of eachother.
envelopeItems = envelopeItems.map((envelopeItem) => ({
...envelopeItem,
documentData: {
...envelopeItem.documentData,
data: envelopeItem.documentData.initialData,
},
}));
}
if (!envelope.qrToken) {
await prisma.envelope.update({
where: { where: {
id: envelope.id, envelopeId: envelope.id,
}, role: {
data: { not: RecipientRole.CC,
qrToken: prefixedId('qr'), },
}, },
}); });
}
const legacyDocumentId = mapSecondaryIdToDocumentId(envelope.secondaryId); // Determine if the document has been rejected by checking if any recipient has rejected it
const rejectedRecipient = recipients.find(
(recipient) => recipient.signingStatus === SigningStatus.REJECTED,
);
const { certificateData, auditLogData } = await getCertificateAndAuditLogData({ const isRejected = Boolean(rejectedRecipient);
legacyDocumentId,
documentMeta: envelope.documentMeta,
settings,
});
// !: The commented out code is our desired implementation but we're seemingly // Get the rejection reason from the rejected recipient
// !: running into issues with inngest parallelism in production. const rejectionReason = rejectedRecipient?.rejectionReason ?? '';
// !: Until this is resolved we will do this sequentially which is slower but
// !: will actually work.
// const decoratePromises: Array<Promise<{ oldDocumentDataId: string; newDocumentDataId: string }>> =
// [];
// for (const envelopeItem of envelopeItems) { const fields = await prisma.field.findMany({
// const task = io.runTask(`decorate-${envelopeItem.id}`, async () => { where: {
// const envelopeItemFields = envelope.envelopeItems.find( envelopeId: envelope.id,
// (item) => item.id === envelopeItem.id, },
// )?.field; include: {
signature: true,
},
});
// if (!envelopeItemFields) { // Skip the field check if the document is rejected
// throw new Error(`Envelope item fields not found for envelope item ${envelopeItem.id}`); if (!isRejected && fieldsContainUnsignedRequiredField(fields)) {
// } throw new Error(`Document ${envelope.id} has unsigned required fields`);
}
// return decorateAndSignPdf({ if (isResealing) {
// envelope, // If we're resealing we want to use the initial data for the document
// envelopeItem, // so we aren't placing fields on top of eachother.
// envelopeItemFields, envelopeItems = envelopeItems.map((envelopeItem) => ({
// isRejected, ...envelopeItem,
// rejectionReason, documentData: {
// certificateData, ...envelopeItem.documentData,
// auditLogData, data: envelopeItem.documentData.initialData,
// }); },
// }); }));
}
// decoratePromises.push(task); if (!envelope.qrToken) {
// } await prisma.envelope.update({
where: {
id: envelope.id,
},
data: {
qrToken: prefixedId('qr'),
},
});
}
// const newDocumentData = await Promise.all(decoratePromises); const legacyDocumentId = mapSecondaryIdToDocumentId(envelope.secondaryId);
// TODO: Remove once parallelization is working const { certificateData, auditLogData } = await getCertificateAndAuditLogData({
const newDocumentData: Array<{ oldDocumentDataId: string; newDocumentDataId: string }> = []; legacyDocumentId,
documentMeta: envelope.documentMeta,
settings,
});
for (const envelopeItem of envelopeItems) { const newDocumentData: Array<{ oldDocumentDataId: string; newDocumentDataId: string }> = [];
const result = await io.runTask(`decorate-${envelopeItem.id}`, async () => {
for (const envelopeItem of envelopeItems) {
const envelopeItemFields = envelope.envelopeItems.find( const envelopeItemFields = envelope.envelopeItems.find(
(item) => item.id === envelopeItem.id, (item) => item.id === envelopeItem.id,
)?.field; )?.field;
@ -235,7 +183,7 @@ export const run = async ({
throw new Error(`Envelope item fields not found for envelope item ${envelopeItem.id}`); throw new Error(`Envelope item fields not found for envelope item ${envelopeItem.id}`);
} }
return decorateAndSignPdf({ const result = await decorateAndSignPdf({
envelope, envelope,
envelopeItem, envelopeItem,
envelopeItemFields, envelopeItemFields,
@ -244,25 +192,10 @@ export const run = async ({
certificateData, certificateData,
auditLogData, auditLogData,
}); });
});
newDocumentData.push(result); newDocumentData.push(result);
} }
const postHog = PostHogServerClient();
if (postHog) {
postHog.capture({
distinctId: nanoid(),
event: 'App: Document Sealed',
properties: {
documentId: envelope.id,
isRejected,
},
});
}
await io.runTask('update-document', async () => {
await prisma.$transaction(async (tx) => { await prisma.$transaction(async (tx) => {
for (const { oldDocumentDataId, newDocumentDataId } of newDocumentData) { for (const { oldDocumentDataId, newDocumentDataId } of newDocumentData) {
const newData = await tx.documentData.findFirstOrThrow({ const newData = await tx.documentData.findFirstOrThrow({
@ -304,18 +237,24 @@ export const run = async ({
}), }),
}); });
}); });
return {
envelopeId: envelope.id,
envelopeStatus: envelope.status,
isRejected,
};
}); });
await io.runTask('send-completed-email', async () => { await io.runTask('send-completed-email', async () => {
let shouldSendCompletedEmail = sendEmail && !isResealing && !isRejected; let shouldSendCompletedEmail = sendEmail && !isResealing && !isRejected;
if (isResealing && !isDocumentCompleted(envelope.status)) { if (isResealing && !isDocumentCompleted(envelopeStatus)) {
shouldSendCompletedEmail = sendEmail; shouldSendCompletedEmail = sendEmail;
} }
if (shouldSendCompletedEmail) { if (shouldSendCompletedEmail) {
await sendCompletedEmail({ await sendCompletedEmail({
id: { type: 'envelopeId', id: envelope.id }, id: { type: 'envelopeId', id: envelopeId },
requestMetadata, requestMetadata,
}); });
} }
@ -323,7 +262,7 @@ export const run = async ({
const updatedEnvelope = await prisma.envelope.findFirstOrThrow({ const updatedEnvelope = await prisma.envelope.findFirstOrThrow({
where: { where: {
id: envelope.id, id: envelopeId,
}, },
include: { include: {
documentMeta: true, documentMeta: true,

View File

@ -116,28 +116,28 @@ async function getTeamInsights(
): Promise<OrganisationDetailedInsights> { ): Promise<OrganisationDetailedInsights> {
const teamsQuery = kyselyPrisma.$kysely const teamsQuery = kyselyPrisma.$kysely
.selectFrom('Team as t') .selectFrom('Team as t')
.leftJoin('Envelope as e', (join) =>
join
.onRef('t.id', '=', 'e.teamId')
.on('e.deletedAt', 'is', null)
.on('e.type', '=', sql.lit(EnvelopeType.DOCUMENT)),
)
.leftJoin('TeamGroup as tg', 'tg.teamId', 't.id')
.leftJoin('OrganisationGroup as og', 'og.id', 'tg.organisationGroupId')
.leftJoin('OrganisationGroupMember as ogm', 'ogm.groupId', 'og.id')
.leftJoin('OrganisationMember as om', 'om.id', 'ogm.organisationMemberId')
.where('t.organisationId', '=', organisationId) .where('t.organisationId', '=', organisationId)
.select((eb) => [ .select([
't.id', 't.id as id',
't.name', 't.name as name',
't.createdAt', 't.createdAt as createdAt',
eb sql<number>`COUNT(DISTINCT om."userId")`.as('memberCount'),
.selectFrom('TeamGroup as tg') (createdAtFrom
.innerJoin('OrganisationGroup as og', 'og.id', 'tg.organisationGroupId') ? sql<number>`COUNT(DISTINCT CASE WHEN e.id IS NOT NULL AND e."createdAt" >= ${createdAtFrom} THEN e.id END)`
.innerJoin('OrganisationGroupMember as ogm', 'ogm.groupId', 'og.id') : sql<number>`COUNT(DISTINCT e.id)`
.innerJoin('OrganisationMember as om', 'om.id', 'ogm.organisationMemberId') ).as('documentCount'),
.whereRef('tg.teamId', '=', 't.id')
.select(sql<number>`count(distinct om."userId")`.as('count'))
.as('memberCount'),
eb
.selectFrom('Envelope as e')
.whereRef('e.teamId', '=', 't.id')
.where('e.deletedAt', 'is', null)
.where('e.type', '=', sql.lit(EnvelopeType.DOCUMENT))
.$if(!!createdAtFrom, (qb) => qb.where('e.createdAt', '>=', createdAtFrom!))
.select(sql<number>`count(e.id)`.as('count'))
.as('documentCount'),
]) ])
.groupBy(['t.id', 't.name', 't.createdAt'])
.orderBy('documentCount', 'desc') .orderBy('documentCount', 'desc')
.limit(perPage) .limit(perPage)
.offset(offset); .offset(offset);
@ -164,38 +164,48 @@ async function getUserInsights(
perPage: number, perPage: number,
createdAtFrom: Date | null, createdAtFrom: Date | null,
): Promise<OrganisationDetailedInsights> { ): Promise<OrganisationDetailedInsights> {
const usersQuery = kyselyPrisma.$kysely const usersBase = kyselyPrisma.$kysely
.selectFrom('OrganisationMember as om') .selectFrom('OrganisationMember as om')
.innerJoin('User as u', 'u.id', 'om.userId') .innerJoin('User as u', 'u.id', 'om.userId')
.where('om.organisationId', '=', organisationId) .where('om.organisationId', '=', organisationId)
.select((eb) => [ .leftJoin('Envelope as e', (join) =>
'u.id', join
'u.name', .onRef('e.userId', '=', 'u.id')
'u.email', .on('e.deletedAt', 'is', null)
'u.createdAt', .on('e.type', '=', sql.lit(EnvelopeType.DOCUMENT)),
eb )
.selectFrom('Envelope as e') .leftJoin('Team as td', (join) =>
.innerJoin('Team as t', 't.id', 'e.teamId') join.onRef('td.id', '=', 'e.teamId').on('td.organisationId', '=', organisationId),
.whereRef('e.userId', '=', 'u.id') )
.where('t.organisationId', '=', organisationId) .leftJoin('Recipient as r', (join) =>
.where('e.deletedAt', 'is', null) join.onRef('r.email', '=', 'u.email').on('r.signedAt', 'is not', null),
.where('e.type', '=', sql.lit(EnvelopeType.DOCUMENT)) )
.$if(!!createdAtFrom, (qb) => qb.where('e.createdAt', '>=', createdAtFrom!)) .leftJoin('Envelope as se', (join) =>
.select(sql<number>`count(e.id)`.as('count')) join
.as('documentCount'), .onRef('se.id', '=', 'r.envelopeId')
eb .on('se.deletedAt', 'is', null)
.selectFrom('Recipient as r') .on('se.type', '=', sql.lit(EnvelopeType.DOCUMENT)),
.innerJoin('Envelope as e', 'e.id', 'r.envelopeId') )
.innerJoin('Team as t', 't.id', 'e.teamId') .leftJoin('Team as ts', (join) =>
.whereRef('r.email', '=', 'u.email') join.onRef('ts.id', '=', 'se.teamId').on('ts.organisationId', '=', organisationId),
.where('r.signedAt', 'is not', null) );
.where('t.organisationId', '=', organisationId)
.where('e.deletedAt', 'is', null) const usersQuery = usersBase
.where('e.type', '=', sql.lit(EnvelopeType.DOCUMENT)) .select([
.$if(!!createdAtFrom, (qb) => qb.where('e.createdAt', '>=', createdAtFrom!)) 'u.id as id',
.select(sql<number>`count(e.id)`.as('count')) 'u.name as name',
.as('signedDocumentCount'), 'u.email as email',
'u.createdAt as createdAt',
(createdAtFrom
? sql<number>`COUNT(DISTINCT CASE WHEN e.id IS NOT NULL AND td.id IS NOT NULL AND e."createdAt" >= ${createdAtFrom} THEN e.id END)`
: sql<number>`COUNT(DISTINCT CASE WHEN td.id IS NOT NULL THEN e.id END)`
).as('documentCount'),
(createdAtFrom
? sql<number>`COUNT(DISTINCT CASE WHEN e.id IS NOT NULL AND td.id IS NOT NULL AND e.status = 'COMPLETED' AND e."createdAt" >= ${createdAtFrom} THEN e.id END)`
: sql<number>`COUNT(DISTINCT CASE WHEN e.id IS NOT NULL AND td.id IS NOT NULL AND e.status = 'COMPLETED' THEN e.id END)`
).as('signedDocumentCount'),
]) ])
.groupBy(['u.id', 'u.name', 'u.email', 'u.createdAt'])
.orderBy('u.createdAt', 'desc') .orderBy('u.createdAt', 'desc')
.limit(perPage) .limit(perPage)
.offset(offset); .offset(offset);
@ -282,51 +292,72 @@ async function getOrganisationSummary(
organisationId: string, organisationId: string,
createdAtFrom: Date | null, createdAtFrom: Date | null,
): Promise<OrganisationSummary> { ): Promise<OrganisationSummary> {
const teamCountQuery = kyselyPrisma.$kysely const summaryQuery = kyselyPrisma.$kysely
.selectFrom('Team') .selectFrom('Organisation as o')
.where('organisationId', '=', organisationId) .where('o.id', '=', organisationId)
.select(sql<number>`count(id)`.as('count'))
.executeTakeFirst();
const memberCountQuery = kyselyPrisma.$kysely
.selectFrom('OrganisationMember')
.where('organisationId', '=', organisationId)
.select(sql<number>`count(id)`.as('count'))
.executeTakeFirst();
const envelopeStatsQuery = kyselyPrisma.$kysely
.selectFrom('Envelope as e')
.innerJoin('Team as t', 't.id', 'e.teamId')
.where('t.organisationId', '=', organisationId)
.where('e.deletedAt', 'is', null)
.where('e.type', '=', sql.lit(EnvelopeType.DOCUMENT))
.select([ .select([
sql<number>`count(e.id)`.as('totalDocuments'), sql<number>`(SELECT COUNT(DISTINCT t2.id) FROM "Team" AS t2 WHERE t2."organisationId" = o.id)`.as(
sql<number>`count(case when e.status in ('DRAFT', 'PENDING') then 1 end)`.as( 'totalTeams',
'activeDocuments',
), ),
sql<number>`count(case when e.status = 'COMPLETED' then 1 end)`.as('completedDocuments'), sql<number>`(SELECT COUNT(DISTINCT om2."userId") FROM "OrganisationMember" AS om2 WHERE om2."organisationId" = o.id)`.as(
sql<number>`count(case when e.status = 'COMPLETED' then 1 end)`.as('volumeAllTime'), 'totalMembers',
),
sql<number>`(
SELECT COUNT(DISTINCT e2.id)
FROM "Envelope" AS e2
INNER JOIN "Team" AS t2 ON t2.id = e2."teamId"
WHERE t2."organisationId" = o.id AND e2."deletedAt" IS NULL AND e2.type = 'DOCUMENT'
)`.as('totalDocuments'),
sql<number>`(
SELECT COUNT(DISTINCT e2.id)
FROM "Envelope" AS e2
INNER JOIN "Team" AS t2 ON t2.id = e2."teamId"
WHERE t2."organisationId" = o.id AND e2."deletedAt" IS NULL AND e2.type = 'DOCUMENT' AND e2.status IN ('DRAFT', 'PENDING')
)`.as('activeDocuments'),
sql<number>`(
SELECT COUNT(DISTINCT e2.id)
FROM "Envelope" AS e2
INNER JOIN "Team" AS t2 ON t2.id = e2."teamId"
WHERE t2."organisationId" = o.id AND e2."deletedAt" IS NULL AND e2.type = 'DOCUMENT' AND e2.status = 'COMPLETED'
)`.as('completedDocuments'),
(createdAtFrom (createdAtFrom
? sql<number>`count(case when e.status = 'COMPLETED' and e."createdAt" >= ${createdAtFrom} then 1 end)` ? sql<number>`(
: sql<number>`count(case when e.status = 'COMPLETED' then 1 end)` SELECT COUNT(DISTINCT e2.id)
FROM "Envelope" AS e2
INNER JOIN "Team" AS t2 ON t2.id = e2."teamId"
WHERE t2."organisationId" = o.id
AND e2."deletedAt" IS NULL
AND e2.type = 'DOCUMENT'
AND e2.status = 'COMPLETED'
AND e2."createdAt" >= ${createdAtFrom}
)`
: sql<number>`(
SELECT COUNT(DISTINCT e2.id)
FROM "Envelope" AS e2
INNER JOIN "Team" AS t2 ON t2.id = e2."teamId"
WHERE t2."organisationId" = o.id
AND e2."deletedAt" IS NULL
AND e2.type = 'DOCUMENT'
AND e2.status = 'COMPLETED'
)`
).as('volumeThisPeriod'), ).as('volumeThisPeriod'),
]) sql<number>`(
.executeTakeFirst(); SELECT COUNT(DISTINCT e2.id)
FROM "Envelope" AS e2
INNER JOIN "Team" AS t2 ON t2.id = e2."teamId"
WHERE t2."organisationId" = o.id AND e2."deletedAt" IS NULL AND e2.type = 'DOCUMENT' AND e2.status = 'COMPLETED'
)`.as('volumeAllTime'),
]);
const [teamCount, memberCount, envelopeStats] = await Promise.all([ const result = await summaryQuery.executeTakeFirst();
teamCountQuery,
memberCountQuery,
envelopeStatsQuery,
]);
return { return {
totalTeams: Number(teamCount?.count || 0), totalTeams: Number(result?.totalTeams || 0),
totalMembers: Number(memberCount?.count || 0), totalMembers: Number(result?.totalMembers || 0),
totalDocuments: Number(envelopeStats?.totalDocuments || 0), totalDocuments: Number(result?.totalDocuments || 0),
activeDocuments: Number(envelopeStats?.activeDocuments || 0), activeDocuments: Number(result?.activeDocuments || 0),
completedDocuments: Number(envelopeStats?.completedDocuments || 0), completedDocuments: Number(result?.completedDocuments || 0),
volumeThisPeriod: Number(envelopeStats?.volumeThisPeriod || 0), volumeThisPeriod: Number(result?.volumeThisPeriod || 0),
volumeAllTime: Number(envelopeStats?.volumeAllTime || 0), volumeAllTime: Number(result?.volumeAllTime || 0),
}; };
} }

View File

@ -33,32 +33,25 @@ export async function getSigningVolume({
let findQuery = kyselyPrisma.$kysely let findQuery = kyselyPrisma.$kysely
.selectFrom('Organisation as o') .selectFrom('Organisation as o')
.where((eb) => .leftJoin('Team as t', 'o.id', 't.organisationId')
eb.or([ .leftJoin('Envelope as e', (join) =>
eb('o.name', 'ilike', `%${search}%`), join
eb.exists( .onRef('t.id', '=', 'e.teamId')
eb .on('e.status', '=', sql.lit(DocumentStatus.COMPLETED))
.selectFrom('Team as t') .on('e.deletedAt', 'is', null)
.whereRef('t.organisationId', '=', 'o.id') .on('e.type', '=', sql.lit(EnvelopeType.DOCUMENT)),
.where('t.name', 'ilike', `%${search}%`),
),
]),
) )
.select((eb) => [ .where((eb) =>
eb.or([eb('o.name', 'ilike', `%${search}%`), eb('t.name', 'ilike', `%${search}%`)]),
)
.select([
'o.id as id', 'o.id as id',
'o.createdAt as createdAt', 'o.createdAt as createdAt',
'o.customerId as customerId', 'o.customerId as customerId',
sql<string>`COALESCE(o.name, 'Unknown')`.as('name'), sql<string>`COALESCE(o.name, 'Unknown')`.as('name'),
eb sql<number>`COUNT(DISTINCT e.id)`.as('signingVolume'),
.selectFrom('Envelope as e') ])
.innerJoin('Team as t', 't.id', 'e.teamId') .groupBy(['o.id', 'o.name', 'o.customerId']);
.whereRef('t.organisationId', '=', 'o.id')
.where('e.status', '=', sql.lit(DocumentStatus.COMPLETED))
.where('e.deletedAt', 'is', null)
.where('e.type', '=', sql.lit(EnvelopeType.DOCUMENT))
.select(sql<number>`count(e.id)`.as('count'))
.as('signingVolume'),
]);
switch (sortBy) { switch (sortBy) {
case 'name': case 'name':
@ -78,18 +71,11 @@ export async function getSigningVolume({
const countQuery = kyselyPrisma.$kysely const countQuery = kyselyPrisma.$kysely
.selectFrom('Organisation as o') .selectFrom('Organisation as o')
.leftJoin('Team as t', 'o.id', 't.organisationId')
.where((eb) => .where((eb) =>
eb.or([ eb.or([eb('o.name', 'ilike', `%${search}%`), eb('t.name', 'ilike', `%${search}%`)]),
eb('o.name', 'ilike', `%${search}%`),
eb.exists(
eb
.selectFrom('Team as t')
.whereRef('t.organisationId', '=', 'o.id')
.where('t.name', 'ilike', `%${search}%`),
),
]),
) )
.select(({ fn }) => [fn.countAll().as('count')]); .select(() => [sql<number>`COUNT(DISTINCT o.id)`.as('count')]);
const [results, [{ count }]] = await Promise.all([findQuery.execute(), countQuery.execute()]); const [results, [{ count }]] = await Promise.all([findQuery.execute(), countQuery.execute()]);
@ -118,77 +104,64 @@ export async function getOrganisationInsights({
const offset = Math.max(page - 1, 0) * perPage; const offset = Math.max(page - 1, 0) * perPage;
const now = new Date(); const now = new Date();
let dateCondition = sql<boolean>`1=1`; let dateCondition = sql`1=1`;
if (startDate && endDate) { if (startDate && endDate) {
dateCondition = sql<boolean>`e."createdAt" >= ${startDate} AND e."createdAt" <= ${endDate}`; dateCondition = sql`e."createdAt" >= ${startDate} AND e."createdAt" <= ${endDate}`;
} else { } else {
switch (dateRange) { switch (dateRange) {
case 'last30days': { case 'last30days': {
const thirtyDaysAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000); const thirtyDaysAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
dateCondition = sql<boolean>`e."createdAt" >= ${thirtyDaysAgo}`; dateCondition = sql`e."createdAt" >= ${thirtyDaysAgo}`;
break; break;
} }
case 'last90days': { case 'last90days': {
const ninetyDaysAgo = new Date(now.getTime() - 90 * 24 * 60 * 60 * 1000); const ninetyDaysAgo = new Date(now.getTime() - 90 * 24 * 60 * 60 * 1000);
dateCondition = sql<boolean>`e."createdAt" >= ${ninetyDaysAgo}`; dateCondition = sql`e."createdAt" >= ${ninetyDaysAgo}`;
break; break;
} }
case 'lastYear': { case 'lastYear': {
const oneYearAgo = new Date(now.getFullYear() - 1, now.getMonth(), now.getDate()); const oneYearAgo = new Date(now.getFullYear() - 1, now.getMonth(), now.getDate());
dateCondition = sql<boolean>`e."createdAt" >= ${oneYearAgo}`; dateCondition = sql`e."createdAt" >= ${oneYearAgo}`;
break; break;
} }
case 'allTime': case 'allTime':
default: default:
dateCondition = sql<boolean>`1=1`; dateCondition = sql`1=1`;
break; break;
} }
} }
let findQuery = kyselyPrisma.$kysely let findQuery = kyselyPrisma.$kysely
.selectFrom('Organisation as o') .selectFrom('Organisation as o')
.leftJoin('Team as t', 'o.id', 't.organisationId')
.leftJoin('Envelope as e', (join) =>
join
.onRef('t.id', '=', 'e.teamId')
.on('e.status', '=', sql.lit(DocumentStatus.COMPLETED))
.on('e.deletedAt', 'is', null)
.on('e.type', '=', sql.lit(EnvelopeType.DOCUMENT)),
)
.leftJoin('OrganisationMember as om', 'o.id', 'om.organisationId')
.leftJoin('Subscription as s', 'o.id', 's.organisationId') .leftJoin('Subscription as s', 'o.id', 's.organisationId')
.where((eb) => .where((eb) =>
eb.or([ eb.or([eb('o.name', 'ilike', `%${search}%`), eb('t.name', 'ilike', `%${search}%`)]),
eb('o.name', 'ilike', `%${search}%`),
eb.exists(
eb
.selectFrom('Team as t')
.whereRef('t.organisationId', '=', 'o.id')
.where('t.name', 'ilike', `%${search}%`),
),
]),
) )
.select((eb) => [ .select([
'o.id as id', 'o.id as id',
'o.createdAt as createdAt', 'o.createdAt as createdAt',
'o.customerId as customerId', 'o.customerId as customerId',
sql<string>`COALESCE(o.name, 'Unknown')`.as('name'), sql<string>`COALESCE(o.name, 'Unknown')`.as('name'),
sql<number>`COUNT(DISTINCT CASE WHEN e.id IS NOT NULL AND ${dateCondition} THEN e.id END)`.as(
'signingVolume',
),
sql<number>`GREATEST(COUNT(DISTINCT t.id), 1)`.as('teamCount'),
sql<number>`COUNT(DISTINCT om."userId")`.as('memberCount'),
sql<string>`CASE WHEN s.status IS NOT NULL THEN s.status ELSE NULL END`.as( sql<string>`CASE WHEN s.status IS NOT NULL THEN s.status ELSE NULL END`.as(
'subscriptionStatus', 'subscriptionStatus',
), ),
eb ])
.selectFrom('Team as t') .groupBy(['o.id', 'o.name', 'o.customerId', 's.status']);
.whereRef('t.organisationId', '=', 'o.id')
.select(sql<number>`count(t.id)`.as('count'))
.as('teamCount'),
eb
.selectFrom('OrganisationMember as om')
.whereRef('om.organisationId', '=', 'o.id')
.select(sql<number>`count(om.id)`.as('count'))
.as('memberCount'),
eb
.selectFrom('Envelope as e')
.innerJoin('Team as t', 't.id', 'e.teamId')
.whereRef('t.organisationId', '=', 'o.id')
.where('e.status', '=', sql.lit(DocumentStatus.COMPLETED))
.where('e.deletedAt', 'is', null)
.where('e.type', '=', sql.lit(EnvelopeType.DOCUMENT))
.where(dateCondition)
.select(sql<number>`count(e.id)`.as('count'))
.as('signingVolume'),
]);
switch (sortBy) { switch (sortBy) {
case 'name': case 'name':
@ -208,18 +181,11 @@ export async function getOrganisationInsights({
const countQuery = kyselyPrisma.$kysely const countQuery = kyselyPrisma.$kysely
.selectFrom('Organisation as o') .selectFrom('Organisation as o')
.leftJoin('Team as t', 'o.id', 't.organisationId')
.where((eb) => .where((eb) =>
eb.or([ eb.or([eb('o.name', 'ilike', `%${search}%`), eb('t.name', 'ilike', `%${search}%`)]),
eb('o.name', 'ilike', `%${search}%`),
eb.exists(
eb
.selectFrom('Team as t')
.whereRef('t.organisationId', '=', 'o.id')
.where('t.name', 'ilike', `%${search}%`),
),
]),
) )
.select(({ fn }) => [fn.countAll().as('count')]); .select(() => [sql<number>`COUNT(DISTINCT o.id)`.as('count')]);
const [results, [{ count }]] = await Promise.all([findQuery.execute(), countQuery.execute()]); const [results, [{ count }]] = await Promise.all([findQuery.execute(), countQuery.execute()]);

View File

@ -103,6 +103,7 @@ export const getDocumentAndSenderByToken = async ({
select: { select: {
name: true, name: true,
teamEmail: true, teamEmail: true,
url: true,
teamGlobalSettings: { teamGlobalSettings: {
select: { select: {
brandingEnabled: true, brandingEnabled: true,

View File

@ -8,7 +8,7 @@ msgstr ""
"Language: pl\n" "Language: pl\n"
"Project-Id-Version: documenso-app\n" "Project-Id-Version: documenso-app\n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"PO-Revision-Date: 2025-11-17 02:33\n" "PO-Revision-Date: 2025-11-20 02:32\n"
"Last-Translator: \n" "Last-Translator: \n"
"Language-Team: Polish\n" "Language-Team: Polish\n"
"Plural-Forms: nplurals=4; plural=(n==1 ? 0 : (n%10>=2 && n%10<=4) && (n%100<12 || n%100>14) ? 1 : n!=1 && (n%10>=0 && n%10<=1) || (n%10>=5 && n%10<=9) || (n%100>=12 && n%100<=14) ? 2 : 3);\n" "Plural-Forms: nplurals=4; plural=(n==1 ? 0 : (n%10>=2 && n%10<=4) && (n%100<12 || n%100>14) ? 1 : n!=1 && (n%10>=0 && n%10<=1) || (n%10>=5 && n%10<=9) || (n%100>=12 && n%100<=14) ? 2 : 3);\n"
@ -179,7 +179,7 @@ msgstr "Sprawdź i {recipientActionVerb} dokument utworzony przez zespół {0}"
#: apps/remix/app/components/general/envelope/envelope-drop-zone-wrapper.tsx #: apps/remix/app/components/general/envelope/envelope-drop-zone-wrapper.tsx
#: apps/remix/app/components/general/document/document-upload-button-legacy.tsx #: apps/remix/app/components/general/document/document-upload-button-legacy.tsx
msgid "{0} of {1} documents remaining this month." msgid "{0} of {1} documents remaining this month."
msgstr "{0} z {1} dokumentów pozostałych w tym miesiącu." msgstr "Pozostało {0} z {1} dokumentów w tym miesiącu."
#. placeholder {0}: table.getFilteredSelectedRowModel().rows.length #. placeholder {0}: table.getFilteredSelectedRowModel().rows.length
#. placeholder {1}: table.getFilteredRowModel().rows.length #. placeholder {1}: table.getFilteredRowModel().rows.length

File diff suppressed because it is too large Load Diff

View File

@ -8,15 +8,17 @@ export type EnvelopeItemPdfUrlOptions =
envelopeItem: Pick<EnvelopeItem, 'id' | 'envelopeId'>; envelopeItem: Pick<EnvelopeItem, 'id' | 'envelopeId'>;
token: string | undefined; token: string | undefined;
version: 'original' | 'signed'; version: 'original' | 'signed';
presignToken?: undefined;
} }
| { | {
type: 'view'; type: 'view';
envelopeItem: Pick<EnvelopeItem, 'id' | 'envelopeId'>; envelopeItem: Pick<EnvelopeItem, 'id' | 'envelopeId'>;
token: string | undefined; token: string | undefined;
presignToken?: string | undefined;
}; };
export const getEnvelopeItemPdfUrl = (options: EnvelopeItemPdfUrlOptions) => { export const getEnvelopeItemPdfUrl = (options: EnvelopeItemPdfUrlOptions) => {
const { envelopeItem, token, type } = options; const { envelopeItem, token, type, presignToken } = options;
const { id, envelopeId } = envelopeItem; const { id, envelopeId } = envelopeItem;
@ -24,11 +26,11 @@ export const getEnvelopeItemPdfUrl = (options: EnvelopeItemPdfUrlOptions) => {
const version = options.version; const version = options.version;
return token return token
? `${NEXT_PUBLIC_WEBAPP_URL()}/api/files/token/${token}/envelopeItem/${id}/download/${version}` ? `${NEXT_PUBLIC_WEBAPP_URL()}/api/files/token/${token}/envelopeItem/${id}/download/${version}${presignToken ? `?presignToken=${presignToken}` : ''}`
: `${NEXT_PUBLIC_WEBAPP_URL()}/api/files/envelope/${envelopeId}/envelopeItem/${id}/download/${version}`; : `${NEXT_PUBLIC_WEBAPP_URL()}/api/files/envelope/${envelopeId}/envelopeItem/${id}/download/${version}`;
} }
return token return token
? `${NEXT_PUBLIC_WEBAPP_URL()}/api/files/token/${token}/envelopeItem/${id}` ? `${NEXT_PUBLIC_WEBAPP_URL()}/api/files/token/${token}/envelopeItem/${id}${presignToken ? `?presignToken=${presignToken}` : ''}`
: `${NEXT_PUBLIC_WEBAPP_URL()}/api/files/envelope/${envelopeId}/envelopeItem/${id}`; : `${NEXT_PUBLIC_WEBAPP_URL()}/api/files/envelope/${envelopeId}/envelopeItem/${id}`;
}; };

View File

@ -1,29 +0,0 @@
-- CreateIndex
CREATE INDEX "Envelope_teamId_deletedAt_type_status_idx" ON "Envelope"("teamId", "deletedAt", "type", "status");
-- CreateIndex
CREATE INDEX "Envelope_teamId_deletedAt_type_createdAt_idx" ON "Envelope"("teamId", "deletedAt", "type", "createdAt");
-- CreateIndex
CREATE INDEX "Envelope_userId_deletedAt_type_idx" ON "Envelope"("userId", "deletedAt", "type");
-- CreateIndex
CREATE INDEX "Envelope_status_deletedAt_type_idx" ON "Envelope"("status", "deletedAt", "type");
-- CreateIndex
CREATE INDEX "Organisation_name_idx" ON "Organisation"("name");
-- CreateIndex
CREATE INDEX "OrganisationMember_organisationId_idx" ON "OrganisationMember"("organisationId");
-- CreateIndex
CREATE INDEX "Recipient_email_idx" ON "Recipient"("email");
-- CreateIndex
CREATE INDEX "Recipient_signedAt_idx" ON "Recipient"("signedAt");
-- CreateIndex
CREATE INDEX "Recipient_envelopeId_signedAt_idx" ON "Recipient"("envelopeId", "signedAt");
-- CreateIndex
CREATE INDEX "Team_organisationId_name_idx" ON "Team"("organisationId", "name");

View File

@ -433,10 +433,6 @@ model Envelope {
@@index([folderId]) @@index([folderId])
@@index([teamId]) @@index([teamId])
@@index([userId]) @@index([userId])
@@index([teamId, deletedAt, type, status])
@@index([teamId, deletedAt, type, createdAt])
@@index([userId, deletedAt, type])
@@index([status, deletedAt, type])
} }
model EnvelopeItem { model EnvelopeItem {
@ -589,9 +585,6 @@ model Recipient {
@@index([envelopeId]) @@index([envelopeId])
@@index([token]) @@index([token])
@@index([email])
@@index([signedAt])
@@index([envelopeId, signedAt])
} }
enum FieldType { enum FieldType {
@ -701,8 +694,6 @@ model Organisation {
organisationAuthenticationPortalId String @unique organisationAuthenticationPortalId String @unique
organisationAuthenticationPortal OrganisationAuthenticationPortal @relation(fields: [organisationAuthenticationPortalId], references: [id]) organisationAuthenticationPortal OrganisationAuthenticationPortal @relation(fields: [organisationAuthenticationPortalId], references: [id])
@@index([name])
} }
model OrganisationMember { model OrganisationMember {
@ -719,7 +710,6 @@ model OrganisationMember {
organisationGroupMembers OrganisationGroupMember[] organisationGroupMembers OrganisationGroupMember[]
@@unique([userId, organisationId]) @@unique([userId, organisationId])
@@index([organisationId])
} }
model OrganisationMemberInvite { model OrganisationMemberInvite {
@ -894,7 +884,6 @@ model Team {
teamGlobalSettings TeamGlobalSettings @relation(fields: [teamGlobalSettingsId], references: [id], onDelete: Cascade) teamGlobalSettings TeamGlobalSettings @relation(fields: [teamGlobalSettingsId], references: [id], onDelete: Cascade)
@@index([organisationId]) @@index([organisationId])
@@index([organisationId, name])
} }
model TeamEmail { model TeamEmail {

View File

@ -25,6 +25,7 @@ import { redistributeEnvelopeRoute } from './redistribute-envelope';
import { setEnvelopeFieldsRoute } from './set-envelope-fields'; import { setEnvelopeFieldsRoute } from './set-envelope-fields';
import { setEnvelopeRecipientsRoute } from './set-envelope-recipients'; import { setEnvelopeRecipientsRoute } from './set-envelope-recipients';
import { signEnvelopeFieldRoute } from './sign-envelope-field'; import { signEnvelopeFieldRoute } from './sign-envelope-field';
import { signingStatusEnvelopeRoute } from './signing-status-envelope';
import { updateEnvelopeRoute } from './update-envelope'; import { updateEnvelopeRoute } from './update-envelope';
import { updateEnvelopeItemsRoute } from './update-envelope-items'; import { updateEnvelopeItemsRoute } from './update-envelope-items';
import { useEnvelopeRoute } from './use-envelope'; import { useEnvelopeRoute } from './use-envelope';
@ -72,4 +73,5 @@ export const envelopeRouter = router({
duplicate: duplicateEnvelopeRoute, duplicate: duplicateEnvelopeRoute,
distribute: distributeEnvelopeRoute, distribute: distributeEnvelopeRoute,
redistribute: redistributeEnvelopeRoute, redistribute: redistributeEnvelopeRoute,
signingStatus: signingStatusEnvelopeRoute,
}); });

View File

@ -0,0 +1,82 @@
import { DocumentStatus, EnvelopeType, RecipientRole, SigningStatus } from '@prisma/client';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { prisma } from '@documenso/prisma';
import { maybeAuthenticatedProcedure } from '../trpc';
import {
ZSigningStatusEnvelopeRequestSchema,
ZSigningStatusEnvelopeResponseSchema,
} from './signing-status-envelope.types';
// Internal route - not intended for public API usage
export const signingStatusEnvelopeRoute = maybeAuthenticatedProcedure
.input(ZSigningStatusEnvelopeRequestSchema)
.output(ZSigningStatusEnvelopeResponseSchema)
.query(async ({ input, ctx }) => {
const { token } = input;
ctx.logger.info({
input: {
token,
},
});
const envelope = await prisma.envelope.findFirst({
where: {
type: EnvelopeType.DOCUMENT,
recipients: {
some: {
token,
},
},
},
include: {
recipients: {
select: {
id: true,
name: true,
email: true,
signingStatus: true,
role: true,
},
},
},
});
if (!envelope) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Envelope not found',
});
}
// Check if envelope is rejected
if (envelope.status === DocumentStatus.REJECTED) {
return {
status: 'REJECTED',
};
}
if (envelope.status === DocumentStatus.COMPLETED) {
return {
status: 'COMPLETED',
};
}
const isComplete =
envelope.recipients.some((recipient) => recipient.signingStatus === SigningStatus.REJECTED) ||
envelope.recipients.every(
(recipient) =>
recipient.role === RecipientRole.CC || recipient.signingStatus === SigningStatus.SIGNED,
);
if (isComplete) {
return {
status: 'PROCESSING',
};
}
return {
status: 'PENDING',
};
});

View File

@ -0,0 +1,14 @@
import { z } from 'zod';
export const EnvelopeSigningStatus = z.enum(['PENDING', 'PROCESSING', 'COMPLETED', 'REJECTED']);
export const ZSigningStatusEnvelopeRequestSchema = z.object({
token: z.string().describe('The recipient token to check the signing status for'),
});
export const ZSigningStatusEnvelopeResponseSchema = z.object({
status: EnvelopeSigningStatus.describe('The current signing status of the envelope'),
});
export type TSigningStatusEnvelopeRequest = z.infer<typeof ZSigningStatusEnvelopeRequestSchema>;
export type TSigningStatusEnvelopeResponse = z.infer<typeof ZSigningStatusEnvelopeResponseSchema>;

View File

@ -127,11 +127,11 @@ export const DocumentShareButton = ({
<Button <Button
variant="outline" variant="outline"
disabled={!token || !documentId} disabled={!token || !documentId}
className={cn('flex-1 text-[11px]', className)} className={cn('h-11 w-full max-w-lg flex-1', className)}
loading={isLoading} loading={isLoading}
> >
{!isLoading && <Sparkles className="mr-2 h-5 w-5" />} {!isLoading && <Sparkles className="mr-2 h-5 w-5" />}
<Trans>Share Signature Card</Trans> <Trans>Share</Trans>
</Button> </Button>
)} )}
</DialogTrigger> </DialogTrigger>

View File

@ -56,6 +56,7 @@ export type PDFViewerProps = {
className?: string; className?: string;
envelopeItem: Pick<EnvelopeItem, 'id' | 'envelopeId'>; envelopeItem: Pick<EnvelopeItem, 'id' | 'envelopeId'>;
token: string | undefined; token: string | undefined;
presignToken?: string | undefined;
version: 'original' | 'signed'; version: 'original' | 'signed';
onDocumentLoad?: (_doc: LoadedPDFDocument) => void; onDocumentLoad?: (_doc: LoadedPDFDocument) => void;
onPageClick?: OnPDFViewerPageClick; onPageClick?: OnPDFViewerPageClick;
@ -67,6 +68,7 @@ export const PDFViewer = ({
className, className,
envelopeItem, envelopeItem,
token, token,
presignToken,
version, version,
onDocumentLoad, onDocumentLoad,
onPageClick, onPageClick,
@ -166,6 +168,7 @@ export const PDFViewer = ({
type: 'view', type: 'view',
envelopeItem: envelopeItem, envelopeItem: envelopeItem,
token, token,
presignToken,
}); });
const bytes = await fetch(documentUrl).then(async (res) => await res.arrayBuffer()); const bytes = await fetch(documentUrl).then(async (res) => await res.arrayBuffer());

View File

@ -119,6 +119,7 @@
"GOOGLE_APPLICATION_CREDENTIALS", "GOOGLE_APPLICATION_CREDENTIALS",
"E2E_TEST_AUTHENTICATE_USERNAME", "E2E_TEST_AUTHENTICATE_USERNAME",
"E2E_TEST_AUTHENTICATE_USER_EMAIL", "E2E_TEST_AUTHENTICATE_USER_EMAIL",
"E2E_TEST_AUTHENTICATE_USER_PASSWORD" "E2E_TEST_AUTHENTICATE_USER_PASSWORD",
"NEXT_PRIVATE_OIDC_PROMPT"
] ]
} }