chore: merged main

This commit is contained in:
Catalin Pit
2026-06-17 12:04:51 +03:00
103 changed files with 6524 additions and 77 deletions
@@ -13,7 +13,7 @@ import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import type { Field, Recipient } from '@prisma/client';
import { useState } from 'react';
import { useNavigate, useSearchParams } from 'react-router';
import { useSearchParams } from 'react-router';
import { useRequiredDocumentSigningAuthContext } from '~/components/general/document-signing/document-signing-auth-provider';
import { useRequiredDocumentSigningContext } from '~/components/general/document-signing/document-signing-provider';
@@ -37,7 +37,6 @@ export const DirectTemplatePageView = ({
directTemplateRecipient,
directTemplateToken,
}: DirectTemplatePageViewProps) => {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const { _ } = useLingui();
@@ -119,7 +118,7 @@ export const DirectTemplatePageView = ({
if (redirectUrl) {
window.location.href = redirectUrl;
} else {
await navigate(`/sign/${token}/complete`);
window.location.href = `/sign/${token}/complete`;
}
} catch (err) {
const error = AppError.parseError(err);
@@ -0,0 +1,68 @@
import { AppErrorCode } from '@documenso/lib/errors/app-error';
import { Button } from '@documenso/ui/primitives/button';
import { Trans } from '@lingui/react/macro';
import { AlertTriangleIcon } from 'lucide-react';
export type CscRecipientBlockedPageProps = {
code: string;
recipientToken: string;
};
/**
* Terminal page rendered when the service-scope CSC OAuth callback surfaces a
* hard error the recipient can't resolve themselves (empty credential list,
* invalid cert, refused algorithm). The blocking-error cookie is read +
* cleared by the loader; this page only renders the message + retry CTA.
*
* The retry link kicks a fresh service-scope OAuth round-trip — useful when
* the TSP-side issue is transient (e.g. the recipient's admin has since
* provisioned a credential).
*/
export const CscRecipientBlockedPage = ({ code, recipientToken }: CscRecipientBlockedPageProps) => {
const retryUrl = `/api/csc/oauth/authorize?scope=service&token=${encodeURIComponent(recipientToken)}`;
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">
<AlertTriangleIcon className="h-12 w-12 text-destructive" />
<h2 className="mt-6 max-w-[35ch] text-center font-semibold text-2xl leading-normal md:text-3xl lg:text-4xl">
{code === AppErrorCode.CSC_CREDENTIAL_LIST_EMPTY ? (
<Trans>No signing credentials available</Trans>
) : code === AppErrorCode.CSC_CERT_INVALID ? (
<Trans>Signing certificate is invalid</Trans>
) : code === AppErrorCode.CSC_ALGORITHM_REFUSED ? (
<Trans>Signing algorithm is not supported</Trans>
) : (
<Trans>Unable to start the signing flow</Trans>
)}
</h2>
<p className="mt-2.5 max-w-[60ch] text-center font-medium text-muted-foreground/60 text-sm md:text-base">
{code === AppErrorCode.CSC_CREDENTIAL_LIST_EMPTY ? (
<Trans>
Your signing provider returned no usable credentials for this account. Contact your administrator or signing
provider for assistance.
</Trans>
) : code === AppErrorCode.CSC_CERT_INVALID ? (
<Trans>
Your signing certificate is invalid, expired, or missing a required key. Contact your administrator or
signing provider for assistance.
</Trans>
) : code === AppErrorCode.CSC_ALGORITHM_REFUSED ? (
<Trans>
Your signing provider does not advertise a signing algorithm this document accepts. Contact your
administrator or signing provider for assistance.
</Trans>
) : (
<Trans>Something went wrong while preparing the remote signature. Please try again.</Trans>
)}
</p>
<Button asChild className="mt-8">
<a href={retryUrl}>
<Trans>Try again</Trans>
</a>
</Button>
</div>
);
};
@@ -0,0 +1,105 @@
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { trpc } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
import { Trans } from '@lingui/react/macro';
import { AlertTriangleIcon, Loader2Icon } from 'lucide-react';
import { useEffect, useRef, useState } from 'react';
export type CscRecipientSigningInProgressPageProps = {
sessionId: string;
recipientToken: string;
};
/**
* Rendered when the credential-scope OAuth callback has attached a SAD to the
* server-side `CscSession` and set the `csc_sad_session` cookie. The page
* auto-fires `enterprise.csc.signEnvelope` on mount and navigates to the
* completion page on success. On failure, it surfaces an error message and
* a retry CTA pointing at a fresh credential-scope OAuth round-trip.
*/
export const CscRecipientSigningInProgressPage = ({
sessionId,
recipientToken,
}: CscRecipientSigningInProgressPageProps) => {
const { mutateAsync: signEnvelope } = trpc.enterprise.csc.signEnvelope.useMutation();
const [error, setError] = useState<string | null>(null);
// Ref rather than state for the fire-once guard. Refs mutate synchronously,
// so React StrictMode's double-invoke of the effect sees the updated value
// on the second pass and short-circuits. A useState guard would still let
// the second effect fire because the queued setState from the first run
// hasn't been committed yet when the second one reads it — that double-fire
// races two signEnvelope calls; whichever loses sees the SAD already
// consumed and flashes "Signing failed" before the winning call's
// navigation kicks in.
const hasFiredRef = useRef(false);
useEffect(() => {
if (hasFiredRef.current) {
return;
}
hasFiredRef.current = true;
const run = async () => {
try {
await signEnvelope({ sessionId, recipientToken });
window.location.href = `/sign/${recipientToken}/complete`;
} catch (err) {
const parsed = AppError.parseError(err);
setError(parsed.code || AppErrorCode.UNKNOWN_ERROR);
}
};
void run();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const retryUrl = `/api/csc/oauth/authorize?scope=credential&session=${encodeURIComponent(sessionId)}`;
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">
{error ? (
<>
<AlertTriangleIcon className="h-12 w-12 text-destructive" />
<h2 className="mt-6 max-w-[35ch] text-center font-semibold text-2xl leading-normal md:text-3xl lg:text-4xl">
<Trans>Signing failed</Trans>
</h2>
<p className="mt-2.5 max-w-[60ch] text-center font-medium text-muted-foreground/60 text-sm md:text-base">
{error === AppErrorCode.CSC_TSP_TIMEOUT ? (
<Trans>The signing provider did not respond in time. Please retry.</Trans>
) : error === AppErrorCode.CSC_SAD_EXPIRED_PRE_SIGN ? (
<Trans>
Your signing authorisation expired before the signature could be applied. Please reauthorise to retry.
</Trans>
) : (
<Trans>Something went wrong while applying your signature. Please retry.</Trans>
)}
</p>
<Button asChild className="mt-8">
<a href={retryUrl}>
<Trans>Reauthorise and retry</Trans>
</a>
</Button>
</>
) : (
<>
<Loader2Icon className="h-12 w-12 animate-spin text-primary" />
<h2 className="mt-6 max-w-[35ch] text-center font-semibold text-2xl leading-normal md:text-3xl lg:text-4xl">
<Trans>Applying your signature</Trans>
</h2>
<p className="mt-2.5 max-w-[60ch] text-center font-medium text-muted-foreground/60 text-sm md:text-base">
<Trans>Please don't close this tab. The signing provider is finalising your signature.</Trans>
</p>
</>
)}
</div>
);
};
@@ -27,7 +27,6 @@ import type { Field } from '@prisma/client';
import { FieldType, RecipientRole } from '@prisma/client';
import { LucideChevronDown, LucideChevronUp } from 'lucide-react';
import { useMemo, useState } from 'react';
import { useNavigate } from 'react-router';
import { match, P } from 'ts-pattern';
import { DocumentSigningAttachmentsPopover } from '~/components/general/document-signing/document-signing-attachments-popover';
@@ -84,7 +83,6 @@ export const DocumentSigningPageViewV1 = ({
? authUser.twoFactorEnabled && authUser.email === recipient.email
: false;
const navigate = useNavigate();
const analytics = useAnalytics();
const [selectedSignerId, setSelectedSignerId] = useState<number | null>(allRecipients?.[0]?.id);
@@ -129,7 +127,7 @@ export const DocumentSigningPageViewV1 = ({
if (documentMeta?.redirectUrl) {
window.location.href = documentMeta.redirectUrl;
} else {
await navigate(`/sign/${recipient.token}/complete`);
window.location.href = `/sign/${recipient.token}/complete`;
}
};
@@ -17,7 +17,7 @@ import { msg } from '@lingui/core/macro';
import { Trans, useLingui } from '@lingui/react/macro';
import { useEffect, useState } from 'react';
import { useForm } from 'react-hook-form';
import { useNavigate, useSearchParams } from 'react-router';
import { useSearchParams } from 'react-router';
import { z } from 'zod';
const ZRejectDocumentFormSchema = z.object({
@@ -41,7 +41,6 @@ export function DocumentSigningRejectDialog({
}: DocumentSigningRejectDialogProps) {
const { t } = useLingui();
const { toast } = useToast();
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const [isOpen, setIsOpen] = useState(false);
@@ -74,7 +73,7 @@ export function DocumentSigningRejectDialog({
if (onRejected) {
await onRejected(reason);
} else {
await navigate(`/sign/${token}/rejected`);
window.location.href = `/sign/${token}/rejected`;
}
} catch (err) {
toast({
@@ -38,7 +38,7 @@ export const DocumentPageViewButton = ({ envelope }: DocumentPageViewButtonProps
})
.with({ isRecipient: true, isPending: true, isSigned: false }, () => (
<Button className="w-full" asChild>
<Link to={`/sign/${recipient?.token}`}>
<a href={`/sign/${recipient?.token}`}>
{match(role)
.with(RecipientRole.SIGNER, () => (
<>
@@ -58,7 +58,7 @@ export const DocumentPageViewButton = ({ envelope }: DocumentPageViewButtonProps
<Trans>View</Trans>
</>
))}
</Link>
</a>
</Button>
))
.with({ isComplete: false }, () => (
@@ -89,7 +89,7 @@ export const EnvelopeSignerCompleteDialog = () => {
recipientDetails?: { name: string; email: string },
) => {
try {
await completeDocument({
const result = await completeDocument({
token: recipient.token,
documentId: mapSecondaryIdToDocumentId(envelope.secondaryId),
accessAuthOptions,
@@ -97,6 +97,15 @@ export const EnvelopeSignerCompleteDialog = () => {
...(nextSigner?.email && nextSigner?.name ? { nextSigner } : {}),
});
// TSP envelopes can't be completed via the SES path; the mutation returns
// a credential-scope OAuth URL the recipient must follow to acquire a SAD
// before the sync sign mutation can run. Short-circuit here so the
// analytics / completion handlers don't run with a still-unsigned doc.
if (result.status === 'REDIRECT') {
window.location.href = result.redirectUrl;
return;
}
analytics.capture('App: Recipient has completed signing', {
signerId: recipient.id,
documentId: envelope.id,
@@ -119,7 +128,7 @@ export const EnvelopeSignerCompleteDialog = () => {
if (envelope.documentMeta.redirectUrl) {
window.location.href = envelope.documentMeta.redirectUrl;
} else {
await navigate(`/sign/${recipient.token}/complete`);
window.location.href = `/sign/${recipient.token}/complete`;
}
} catch (err) {
const error = AppError.parseError(err);
@@ -197,7 +206,7 @@ export const EnvelopeSignerCompleteDialog = () => {
if (redirectUrl) {
window.location.href = redirectUrl;
} else {
await navigate(`/sign/${token}/complete`);
window.location.href = `/sign/${token}/complete`;
}
} catch (err) {
console.log('err', err);
@@ -66,7 +66,7 @@ export const DocumentsTableActionButton = ({ row }: DocumentsTableActionButtonPr
))
.with({ isRecipient: true, isPending: true, isSigned: false }, () => (
<Button className="w-32" asChild>
<Link to={`/sign/${recipient?.token}`}>
<a href={`/sign/${recipient?.token}`}>
{match(role)
.with(RecipientRole.SIGNER, () => (
<>
@@ -86,7 +86,7 @@ export const DocumentsTableActionButton = ({ row }: DocumentsTableActionButtonPr
<Trans>View</Trans>
</>
))}
</Link>
</a>
</Button>
))
.with({ isPending: true, isSigned: true }, () => (
@@ -105,7 +105,7 @@ export const DocumentsTableActionDropdown = ({ row, onMoveDocument }: DocumentsT
recipient?.role !== RecipientRole.CC &&
recipient?.role !== RecipientRole.ASSISTANT && (
<DropdownMenuItem disabled={!recipient || isComplete} asChild>
<Link to={`/sign/${recipient?.token}`}>
<a href={`/sign/${recipient?.token}`}>
{recipient?.role === RecipientRole.VIEWER && (
<>
<EyeIcon className="mr-2 h-4 w-4" />
@@ -126,7 +126,7 @@ export const DocumentsTableActionDropdown = ({ row, onMoveDocument }: DocumentsT
<Trans>Approve</Trans>
</>
)}
</Link>
</a>
</DropdownMenuItem>
)}
@@ -17,7 +17,7 @@ import { DocumentStatus as DocumentStatusEnum, RecipientRole, SigningStatus } fr
import { CheckCircleIcon, DownloadIcon, EyeIcon, Loader, PencilIcon } from 'lucide-react';
import { DateTime } from 'luxon';
import { useMemo, useTransition } from 'react';
import { Link, useSearchParams } from 'react-router';
import { useSearchParams } from 'react-router';
import { match } from 'ts-pattern';
import { DocumentStatus } from '~/components/general/document/document-status';
@@ -200,7 +200,7 @@ export const InboxTableActionButton = ({ row }: InboxTableActionButtonProps) =>
})
.with({ isPending: true, isSigned: false }, () => (
<Button className="w-32" asChild>
<Link to={`/sign/${recipient?.token}`}>
<a href={`/sign/${recipient?.token}`}>
{match(role)
.with(RecipientRole.SIGNER, () => (
<>
@@ -220,7 +220,7 @@ export const InboxTableActionButton = ({ row }: InboxTableActionButtonProps) =>
<Trans>View</Trans>
</>
))}
</Link>
</a>
</Button>
))
.with({ isPending: true, isSigned: true }, () => (
@@ -1,7 +1,14 @@
import signingCelebration from '@documenso/assets/images/signing-celebration.png';
import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session';
import {
buildClearCscBlockingErrorCookieHeader,
readCscBlockingErrorFromRequest,
} from '@documenso/ee/server-only/signing/csc/cookies/blocking-error-cookie';
import { readCscSadSessionFromRequest } from '@documenso/ee/server-only/signing/csc/cookies/sad-session-cookie';
import { readCscServiceSessionFromRequest } from '@documenso/ee/server-only/signing/csc/cookies/service-session-cookie';
import { EnvelopeRenderProvider } from '@documenso/lib/client-only/providers/envelope-render-provider';
import { useOptionalSession } from '@documenso/lib/client-only/providers/session';
import { IS_INSTANCE_CSC_MODE } from '@documenso/lib/constants/app';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { loadRecipientBrandingByTeamId } from '@documenso/lib/server-only/branding/load-recipient-branding';
import { getDocumentAndSenderByToken } from '@documenso/lib/server-only/document/get-document-by-token';
@@ -18,6 +25,7 @@ import { getRecipientsForAssistant } from '@documenso/lib/server-only/recipient/
import { getTeamSettings } from '@documenso/lib/server-only/team/get-team-settings';
import { getUserByEmail } from '@documenso/lib/server-only/user/get-user-by-email';
import { DocumentAccessAuth } from '@documenso/lib/types/document-auth';
import { isTspEnvelope } from '@documenso/lib/types/signature-level';
import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth';
import { isRecipientExpired } from '@documenso/lib/utils/recipients';
import { prisma } from '@documenso/prisma';
@@ -30,6 +38,8 @@ import { getOptionalLoaderContext } from 'server/utils/get-loader-session';
import { match } from 'ts-pattern';
import { Header as AuthenticatedHeader } from '~/components/general/app-header';
import { CscRecipientBlockedPage } from '~/components/general/document-signing/csc-recipient-blocked-page';
import { CscRecipientSigningInProgressPage } from '~/components/general/document-signing/csc-recipient-signing-in-progress-page';
import { DocumentSigningAuthPageView } from '~/components/general/document-signing/document-signing-auth-page';
import { DocumentSigningAuthProvider } from '~/components/general/document-signing/document-signing-auth-provider';
import { DocumentSigningPageViewV1 } from '~/components/general/document-signing/document-signing-page-view-v1';
@@ -257,6 +267,58 @@ const handleV2Loader = async ({ params, request }: Route.LoaderArgs) => {
recipientAccessAuth: derivedRecipientAccessAuth,
}).catch(() => null);
// CSC / TSP routing. TSP envelopes have three terminal recipient-page
// states beyond the normal signing UI:
// 1. `blocked` — service-scope OAuth returned a hard error (set by the
// callback as a one-shot `csc_blocking_error` cookie).
// 2. `signing-in-progress` — credential-scope OAuth completed, SAD is
// attached server-side, page auto-fires the sync sign mutation.
// 3. pre-auth — no service token yet, kick the recipient into
// service-scope OAuth.
// The fourth state (service session valid, no SAD, no blocking error) falls
// through to the normal signing UI.
if (IS_INSTANCE_CSC_MODE() && isTspEnvelope(envelope)) {
const blockingError = await readCscBlockingErrorFromRequest(request);
if (blockingError && blockingError.recipientToken === token) {
return {
isDocumentAccessValid: true,
envelopeForSigning,
csc: { state: 'blocked', code: blockingError.code } as const,
responseHeaders: { 'Set-Cookie': buildClearCscBlockingErrorCookieHeader() },
} as const;
}
const sadSessionId = await readCscSadSessionFromRequest(request);
if (sadSessionId) {
const cscSession = await prisma.cscSession.findUnique({
where: { id: sadSessionId },
});
const isSadSessionValid =
cscSession !== null &&
cscSession.recipientId === recipient.id &&
cscSession.encryptedSad !== null &&
cscSession.sadExpiresAt !== null &&
cscSession.sadExpiresAt > new Date();
if (isSadSessionValid) {
return {
isDocumentAccessValid: true,
envelopeForSigning,
csc: { state: 'signing-in-progress', sessionId: sadSessionId } as const,
} as const;
}
}
const serviceSessionToken = await readCscServiceSessionFromRequest(request);
if (serviceSessionToken !== token) {
throw redirect(`/api/csc/oauth/authorize?scope=service&token=${encodeURIComponent(token)}`);
}
}
return {
isDocumentAccessValid: true,
envelopeForSigning,
@@ -296,11 +358,22 @@ export async function loader(loaderArgs: Route.LoaderArgs) {
if (foundRecipient.envelope.internalVersion === 2) {
const payloadV2 = await handleV2Loader(loaderArgs);
return superLoaderJson({
version: 2,
payload: payloadV2,
branding,
} as const);
// V2 payload may carry a one-shot `Set-Cookie` header (used to clear the
// CSC blocking-error cookie after the loader reads it). Forward it via
// the `superLoaderJson` response init so the browser actually applies the
// header. The field stays on the payload — it's just a `Max-Age=0` clear
// directive, not sensitive — and isn't read by any consumer.
const responseHeaders =
'responseHeaders' in payloadV2 && payloadV2.responseHeaders ? payloadV2.responseHeaders : undefined;
return superLoaderJson(
{
version: 2,
payload: payloadV2,
branding,
} as const,
responseHeaders ? { headers: responseHeaders } : undefined,
);
}
const payloadV1 = await handleV1Loader(loaderArgs);
@@ -430,6 +503,19 @@ const SigningPageV2 = ({ data }: { data: Awaited<ReturnType<typeof handleV2Loade
return <DocumentSigningAuthPageView email={data.recipientEmail} emailHasAccount={!!data.recipientHasAccount} />;
}
if ('csc' in data && data.csc?.state === 'blocked') {
return <CscRecipientBlockedPage code={data.csc.code} recipientToken={data.envelopeForSigning.recipient.token} />;
}
if ('csc' in data && data.csc?.state === 'signing-in-progress') {
return (
<CscRecipientSigningInProgressPage
sessionId={data.csc.sessionId}
recipientToken={data.envelopeForSigning.recipient.token}
/>
);
}
const { envelope, recipientSignature, recipient } = data.envelopeForSigning;
if (envelope.deletedAt || envelope.status === DocumentStatus.REJECTED) {
+4
View File
@@ -1,5 +1,6 @@
import { tsRestHonoApp } from '@documenso/api/hono';
import { auth } from '@documenso/auth/server';
import { csc } from '@documenso/ee/server-only/signing/csc/hono';
import { jobsClient } from '@documenso/lib/jobs/client';
import { LicenseClient } from '@documenso/lib/server-only/license/license-client';
import { createRateLimitMiddleware } from '@documenso/lib/server-only/rate-limit/rate-limit-middleware';
@@ -111,6 +112,9 @@ app.route('/api/files', filesRoute);
app.use('/api/ai/*', aiRateLimitMiddleware);
app.route('/api/ai', aiRoute);
// CSC OAuth routes (mounted from @documenso/ee).
app.route('/api/csc', csc);
// API servers.
app.route('/api/v1', tsRestHonoApp);
app.use('/api/jobs/*', jobsClient.getApiHandler());