mirror of
https://github.com/documenso/documenso.git
synced 2026-07-24 17:04:12 +10:00
feat: add CSC AES/QES signing (v1 instance-wide config) (#2874)
Adds Cloud Signature Consortium (CSC) integration for AES/QES signing against a configured TSP. v1 ships as instance-wide configuration via environment variables, with per-envelope signature level selection, license gating, and an OAuth-driven signing flow (capture + FIFO signers, SAD session, blocking/in-progress recipient pages). Includes signature level compatibility checks (role, signing order, dictate next signer), envelope mutability assertions, Prisma migration for signature level and CSC tables, and docs for the new signing certificate options.
This commit is contained in:
@@ -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>
|
||||
);
|
||||
};
|
||||
+105
@@ -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>
|
||||
);
|
||||
};
|
||||
+1
-3
@@ -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`;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
+2
-3
@@ -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 }, () => (
|
||||
|
||||
+12
-3
@@ -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) {
|
||||
|
||||
@@ -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());
|
||||
|
||||
Reference in New Issue
Block a user