From 693ee7002b4170fb798239ade2fc9fc6ae3ea3dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89mile=20R=C3=A9?= Date: Thu, 30 Jul 2026 09:48:48 +0200 Subject: [PATCH] Redirect viewer export to the NDA gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Document export is a mutation, so NDA_SIGNATURE_REQUIRED never reaches the route boundary. Catch it in the export hook, stop re-firing on mutate identity churn, and match gate errors by name when instanceof fails across package copies. Signed-off-by: Émile Ré --- .../src/lib/auth/continueUrl.ts | 10 +- .../pages/documents/_lib/useDocumentExport.ts | 154 +++++++++++++----- 2 files changed, 124 insertions(+), 40 deletions(-) diff --git a/apps/compliance-portal/src/lib/auth/continueUrl.ts b/apps/compliance-portal/src/lib/auth/continueUrl.ts index c925ad05a..f5b19ef18 100644 --- a/apps/compliance-portal/src/lib/auth/continueUrl.ts +++ b/apps/compliance-portal/src/lib/auth/continueUrl.ts @@ -87,15 +87,21 @@ export function buildSubscribeContinueUrl(): string { // resumes) once the gate is cleared. Returns null for non-gate errors. Shared // by the route boundaries and the request-access flows so all gate handling // stays in one place. +function isGateError(error: unknown, ctor: new (...args: never[]) => Error, name: string): boolean { + return error instanceof ctor || (error instanceof Error && error.name === name); +} + export function gateRedirectPath( error: unknown, continueUrl: string, locale: UrlLocale = resolveUrlLocale(), ): string | null { - if (error instanceof FullNameRequiredError) { + // Prefer instanceof; fall back to `name` so a duplicated @probo/relay copy + // (or a wrapped error that preserved the name) still redirects. + if (isGateError(error, FullNameRequiredError, "FullNameRequiredError")) { return `${localizedPath(locale, "/full-name")}?continue=${encodeURIComponent(continueUrl)}`; } - if (error instanceof NDASignatureRequiredError) { + if (isGateError(error, NDASignatureRequiredError, "NDASignatureRequiredError")) { return `${localizedPath(locale, "/nda")}?continue=${encodeURIComponent(continueUrl)}`; } return null; diff --git a/apps/compliance-portal/src/pages/documents/_lib/useDocumentExport.ts b/apps/compliance-portal/src/pages/documents/_lib/useDocumentExport.ts index 6265e3655..6e33163fe 100644 --- a/apps/compliance-portal/src/pages/documents/_lib/useDocumentExport.ts +++ b/apps/compliance-portal/src/pages/documents/_lib/useDocumentExport.ts @@ -18,9 +18,15 @@ // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE // SOFTWARE. +import { Toast } from "@base-ui/react/toast"; +import { UnAuthenticatedError } from "@probo/relay"; import { useEffect, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; import { graphql } from "react-relay"; +import { useNavigate } from "react-router"; +import { gateRedirectPath, redirectToInitiate } from "#/lib/auth/continueUrl"; +import { useLocale } from "#/lib/i18n/useLocale"; import { useMutation } from "#/lib/relay/useMutation"; import type { useDocumentExportDocumentMutation } from "./__generated__/useDocumentExportDocumentMutation.graphql"; @@ -59,13 +65,50 @@ interface DocumentExportState { isExporting: boolean; } -// Exports the aliased node's (watermarked) bytes for the viewer. Fires the -// export mutation matching the node kind once `enabled`, and resets when the -// target id changes. Failures surface through the mutation notifier's toast. +// Exports the aliased node's (watermarked) bytes for the viewer. Fires once per +// (kind, id) while enabled. Mutation failures cannot reach a route error +// boundary, so full-name / NDA gates redirect here (same as request-access); +// other failures toast once. Mutate functions are read from a ref so their +// identity churn (in-flight flag, toast notifier) cannot re-trigger the effect. export function useDocumentExport(kind: DocumentKind, id: string, enabled: boolean): DocumentExportState { - const [exportDocument, isExportingDocument] = useMutation(exportDocumentMutation); - const [exportFile, isExportingFile] = useMutation(exportFileMutation); - const [exportReport, isExportingReport] = useMutation(exportReportMutation); + const navigate = useNavigate(); + const locale = useLocale(); + const toast = Toast.useToastManager(); + const { t } = useTranslation(); + + const [exportDocument, isExportingDocument] = useMutation( + exportDocumentMutation, + { errorToast: false }, + ); + const [exportFile, isExportingFile] = useMutation( + exportFileMutation, + { errorToast: false }, + ); + const [exportReport, isExportingReport] = useMutation( + exportReportMutation, + { errorToast: false }, + ); + + const latest = useRef({ + exportDocument, + exportFile, + exportReport, + navigate, + locale, + toast, + t, + }); + useEffect(() => { + latest.current = { + exportDocument, + exportFile, + exportReport, + navigate, + locale, + toast, + t, + }; + }); const [dataUri, setDataUri] = useState(null); @@ -77,45 +120,80 @@ export function useDocumentExport(kind: DocumentKind, id: string, enabled: boole setDataUri(null); } - // Track the current target so a slow export that resolves after the id - // changed cannot overwrite the preview with the previous document's bytes. - const currentId = useRef(id); useEffect(() => { - currentId.current = id; - }, [id]); - - useEffect(() => { - if (!enabled || dataUri) { + if (!enabled) { return; } - const apply = (targetId: string, data: string) => { - if (currentId.current === targetId) { - setDataUri(data); + let cancelled = false; + const { + exportDocument: exportDoc, + exportFile: exportFil, + exportReport: exportRep, + } = latest.current; + + const handleError = (error: unknown) => { + if (cancelled) { + return; + } + + const continueUrl = window.location.href; + const err = error instanceof Error ? error : new Error(String(error)); + + if (err instanceof UnAuthenticatedError || err.name === "UnAuthenticatedError") { + redirectToInitiate(continueUrl); + return; + } + + const gatePath = gateRedirectPath(err, continueUrl, latest.current.locale); + if (gatePath) { + void latest.current.navigate(gatePath); + return; + } + + latest.current.toast.add({ title: latest.current.t("common.error"), type: "error" }); + }; + + const run = async () => { + try { + let data: string; + switch (kind) { + case "Document": { + const response = await exportDoc({ + variables: { input: { documentId: id } }, + }); + data = response.exportDocumentPDF.data; + break; + } + case "CompliancePortalFile": { + const response = await exportFil({ + variables: { input: { compliancePortalFileId: id } }, + }); + data = response.exportCompliancePortalFile.data; + break; + } + case "AuditReport": { + const response = await exportRep({ + variables: { input: { reportId: id } }, + }); + data = response.exportReportPDF.data; + break; + } + } + if (!cancelled) { + setDataUri(data); + } + } catch (error) { + handleError(error); } }; - switch (kind) { - case "Document": - exportDocument({ - variables: { input: { documentId: id } }, - onCompleted: response => apply(id, response.exportDocumentPDF.data), - }).catch(() => {}); - break; - case "CompliancePortalFile": - exportFile({ - variables: { input: { compliancePortalFileId: id } }, - onCompleted: response => apply(id, response.exportCompliancePortalFile.data), - }).catch(() => {}); - break; - case "AuditReport": - exportReport({ - variables: { input: { reportId: id } }, - onCompleted: response => apply(id, response.exportReportPDF.data), - }).catch(() => {}); - break; - } - }, [enabled, dataUri, kind, id, exportDocument, exportFile, exportReport]); + void run(); + + return () => { + cancelled = true; + }; + }, [enabled, kind, id]); return { dataUri, isExporting: isExportingDocument || isExportingFile || isExportingReport }; }