);
diff --git a/apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.billing.tsx b/apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.billing.tsx
index 652e35255..650bcd3e8 100644
--- a/apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.billing.tsx
+++ b/apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.billing.tsx
@@ -6,6 +6,8 @@ import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { SubscriptionStatus } from '@prisma/client';
import { Loader } from 'lucide-react';
+import { useEffect, useRef } from 'react';
+import { useSearchParams } from 'react-router';
import type Stripe from 'stripe';
import { match, P } from 'ts-pattern';
@@ -23,12 +25,51 @@ export default function TeamsSettingBillingPage() {
const organisation = useCurrentOrganisation();
+ const [searchParams, setSearchParams] = useSearchParams();
+ const utils = trpc.useUtils();
+
const { data: subscriptionQuery, isLoading: isLoadingSubscription } =
trpc.enterprise.billing.subscription.get.useQuery({
organisationId: organisation.id,
});
- if (isLoadingSubscription || !subscriptionQuery) {
+ const { mutateAsync: syncSubscription, isPending: isSyncingSubscription } =
+ trpc.enterprise.billing.subscription.sync.useMutation();
+
+ const hasTriggeredCheckoutSyncRef = useRef(false);
+
+ const isCheckoutSuccess = searchParams.get('success') === 'true';
+
+ /**
+ * Eagerly sync the subscription from Stripe when returning from a successful
+ * checkout, since the webhook may not have arrived yet.
+ */
+ useEffect(() => {
+ if (!isCheckoutSuccess || hasTriggeredCheckoutSyncRef.current) {
+ return;
+ }
+
+ hasTriggeredCheckoutSyncRef.current = true;
+
+ void syncSubscription({ organisationId: organisation.id })
+ .catch(() => {
+ // Non-fatal, webhooks will converge the subscription state shortly.
+ })
+ .finally(() => {
+ void utils.enterprise.billing.invalidate();
+
+ setSearchParams(
+ (params) => {
+ params.delete('success');
+
+ return params;
+ },
+ { replace: true },
+ );
+ });
+ }, [isCheckoutSuccess, organisation.id]);
+
+ if (isLoadingSubscription || !subscriptionQuery || isSyncingSubscription) {
return (
diff --git a/apps/remix/app/routes/_authenticated+/t.$teamUrl+/_layout.tsx b/apps/remix/app/routes/_authenticated+/t.$teamUrl+/_layout.tsx
index 1e6a92110..6d40357b2 100644
--- a/apps/remix/app/routes/_authenticated+/t.$teamUrl+/_layout.tsx
+++ b/apps/remix/app/routes/_authenticated+/t.$teamUrl+/_layout.tsx
@@ -1,6 +1,7 @@
import { DEFAULT_MINIMUM_ENVELOPE_ITEM_COUNT, PAID_PLAN_LIMITS } from '@documenso/ee/server-only/limits/constants';
import { LimitsProvider } from '@documenso/ee/server-only/limits/provider/client';
import { useOptionalCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
+import { isOrganisationPendingPayment } from '@documenso/lib/utils/billing';
import { TrpcProvider } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
import { msg } from '@lingui/core/macro';
@@ -21,7 +22,11 @@ export default function Layout() {
return undefined;
}
- if (organisation?.subscription && organisation.subscription.status === SubscriptionStatus.INACTIVE) {
+ const isRestricted =
+ (organisation.subscription && organisation.subscription.status === SubscriptionStatus.INACTIVE) ||
+ isOrganisationPendingPayment(organisation);
+
+ if (isRestricted) {
return {
quota: {
documents: 0,
@@ -42,7 +47,7 @@ export default function Layout() {
remaining: PAID_PLAN_LIMITS,
maximumEnvelopeItemCount: DEFAULT_MINIMUM_ENVELOPE_ITEM_COUNT,
};
- }, [organisation?.subscription]);
+ }, [organisation]);
if (!team) {
return (
diff --git a/apps/remix/app/routes/_recipient+/report.$token.tsx b/apps/remix/app/routes/_recipient+/report.$token.tsx
new file mode 100644
index 000000000..ec836e5d3
--- /dev/null
+++ b/apps/remix/app/routes/_recipient+/report.$token.tsx
@@ -0,0 +1,92 @@
+import { SUPPORT_EMAIL } from '@documenso/lib/constants/app';
+import { prisma } from '@documenso/prisma';
+import { trpc } from '@documenso/trpc/react';
+import { Button } from '@documenso/ui/primitives/button';
+import { useToast } from '@documenso/ui/primitives/use-toast';
+import { Trans, useLingui } from '@lingui/react/macro';
+import { useState } from 'react';
+
+import type { Route } from './+types/report.$token';
+
+export async function loader({ params }: Route.LoaderArgs) {
+ const { token } = params;
+
+ if (!token) {
+ throw new Response('Not Found', { status: 404 });
+ }
+
+ // Only validate the token on GET. The report itself is performed by an explicit
+ // mutation (triggered by the recipient clicking the button), so an automated email
+ // link scanner / prefetcher cannot register a report simply by fetching the URL.
+ const recipient = await prisma.recipient.findFirst({
+ where: { token },
+ select: { id: true },
+ });
+
+ if (!recipient) {
+ throw new Response('Not Found', { status: 404 });
+ }
+
+ return {
+ token,
+ };
+}
+
+export default function ReportSenderPage({ loaderData }: Route.ComponentProps) {
+ const { token } = loaderData;
+
+ const { t } = useLingui();
+ const { toast } = useToast();
+
+ const [isReported, setIsReported] = useState(false);
+
+ const { mutate: reportSender, isPending } = trpc.envelope.recipient.report.useMutation({
+ onSuccess: () => setIsReported(true),
+ onError: () => {
+ toast({
+ title: t`Something went wrong`,
+ description: t`We were unable to report this sender at this time. Please try again later.`,
+ variant: 'destructive',
+ });
+ },
+ });
+
+ if (isReported) {
+ return (
+
+
+ Sender reported
+
+
+
+
+ Thank you for letting us know, we have flagged this sender for review. If you have any concerns please feel
+ free to reach out to our{' '}
+
+ support team
+
+ .
+
+
+
+ );
+ }
+
+ return (
+
+
+ Report this sender?
+
+
+
+
+ If you did not expect this email or believe it is spam, you can report the sender to our team for review.
+
+
+
+
+
+ );
+}
diff --git a/apps/remix/app/routes/_recipient+/sign.$token+/_index.tsx b/apps/remix/app/routes/_recipient+/sign.$token+/_index.tsx
index 0d3459a1f..d35313fd8 100644
--- a/apps/remix/app/routes/_recipient+/sign.$token+/_index.tsx
+++ b/apps/remix/app/routes/_recipient+/sign.$token+/_index.tsx
@@ -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';
@@ -164,6 +174,10 @@ const handleV1Loader = async ({ params, request }: Route.LoaderArgs) => {
recipientSignature,
isRecipientsTurn,
includeSenderDetails: settings.includeSenderDetails,
+ branding: {
+ brandingEnabled: settings.brandingEnabled,
+ brandingLogo: settings.brandingLogo,
+ },
} as const;
};
@@ -253,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,
@@ -292,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);
@@ -338,6 +415,7 @@ const SigningPageV1 = ({ data }: { data: Awaited
- "{document.title}"
- is no longer available to sign
+ "{document.title}" is no longer available to sign
@@ -410,6 +487,7 @@ const SigningPageV1 = ({ data }: { data: Awaited
;
+ }
+
+ if ('csc' in data && data.csc?.state === 'signing-in-progress') {
+ return (
+
+ );
+ }
+
const { envelope, recipientSignature, recipient } = data.envelopeForSigning;
if (envelope.deletedAt || envelope.status === DocumentStatus.REJECTED) {
@@ -446,8 +537,7 @@ const SigningPageV2 = ({ data }: { data: Awaited