Handle auth gates in every portal mutation

Mutations never reach route error boundaries, so each call
site reimplemented sign-in / full-name / NDA redirects.
Consume those gates in the shared useMutation notifier and
drop the duplicated handlers.

Signed-off-by: Émile Ré <emile@probo.com>
This commit is contained in:
Émile Ré
2026-07-30 10:08:51 +02:00
parent 693ee7002b
commit c96bf22e1c
7 changed files with 160 additions and 228 deletions

View File

@@ -18,7 +18,11 @@
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE. // SOFTWARE.
import { FullNameRequiredError, NDASignatureRequiredError } from "@probo/relay"; import {
FullNameRequiredError,
NDASignatureRequiredError,
UnAuthenticatedError,
} from "@probo/relay";
import { localizedPath, resolveUrlLocale, type UrlLocale } from "#/lib/i18n/locale"; import { localizedPath, resolveUrlLocale, type UrlLocale } from "#/lib/i18n/locale";
@@ -85,8 +89,8 @@ export function buildSubscribeContinueUrl(): string {
// Maps a caught auth-gate error to the route that resolves it, carrying the // Maps a caught auth-gate error to the route that resolves it, carrying the
// given `continueUrl` so the user returns here (and any deferred request // given `continueUrl` so the user returns here (and any deferred request
// resumes) once the gate is cleared. Returns null for non-gate errors. Shared // 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 // by the route boundaries and consumeAuthGate (useMutation) so all gate
// stays in one place. // handling stays in one place.
function isGateError(error: unknown, ctor: new (...args: never[]) => Error, name: string): boolean { function isGateError(error: unknown, ctor: new (...args: never[]) => Error, name: string): boolean {
return error instanceof ctor || (error instanceof Error && error.name === name); return error instanceof ctor || (error instanceof Error && error.name === name);
} }
@@ -115,3 +119,28 @@ export function redirectToInitiate(continueTo: string): void {
initiateURL.searchParams.set("continue", getSafeContinueUrl(continueTo)); initiateURL.searchParams.set("continue", getSafeContinueUrl(continueTo));
window.location.href = initiateURL.toString(); window.location.href = initiateURL.toString();
} }
// Consumes an auth-gate error from a mutation (or similar async path): redirects
// to OAuth /initiate, the full-name page, or the NDA page as appropriate.
// Returns true when the error was a gate and navigation was kicked off, so the
// caller can skip toasts / local error UI. Used by the portal's useMutation
// binding so every mutation gets this for free.
export function consumeAuthGate(
error: unknown,
continueUrl: string,
navigate: (to: string) => void,
locale: UrlLocale = resolveUrlLocale(),
): boolean {
if (isGateError(error, UnAuthenticatedError, "UnAuthenticatedError")) {
redirectToInitiate(continueUrl);
return true;
}
const gatePath = gateRedirectPath(error, continueUrl, locale);
if (gatePath) {
navigate(gatePath);
return true;
}
return false;
}

View File

@@ -18,21 +18,17 @@
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE. // SOFTWARE.
import { Toast } from "@base-ui/react/toast";
import { useEffect, useRef } from "react"; import { useEffect, useRef } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useNavigate, useSearchParams } from "react-router"; import { useSearchParams } from "react-router";
import type { PayloadError } from "relay-runtime";
import { graphql } from "relay-runtime"; import { graphql } from "relay-runtime";
import { import {
buildRequestAccessContinueUrl, buildRequestAccessContinueUrl,
gateRedirectPath,
REQUEST_DOCUMENT_PARAM, REQUEST_DOCUMENT_PARAM,
REQUEST_FILE_PARAM, REQUEST_FILE_PARAM,
REQUEST_REPORT_PARAM, REQUEST_REPORT_PARAM,
} from "#/lib/auth/continueUrl"; } from "#/lib/auth/continueUrl";
import { useLocale } from "#/lib/i18n/useLocale";
import { useMutation } from "#/lib/relay/useMutation"; import { useMutation } from "#/lib/relay/useMutation";
import type { useResumeAccessRequest_documentMutation } from "./__generated__/useResumeAccessRequest_documentMutation.graphql"; import type { useResumeAccessRequest_documentMutation } from "./__generated__/useResumeAccessRequest_documentMutation.graphql";
@@ -87,27 +83,30 @@ const requestFileMutation = graphql`
// After a user signs in through OAuth /initiate, they land back on the page that // After a user signs in through OAuth /initiate, they land back on the page that
// carried a deferred access marker. This hook fires the matching mutation once // carried a deferred access marker. This hook fires the matching mutation once
// (when authenticated) — a single document / report / file requested from a // (when authenticated) — a single document / report / file requested from a
// locked row — routes to the full-name gate when the backend asks for it, and // locked row — and clears the marker so a refresh never re-triggers it. Auth
// clears the marker so a refresh never re-triggers it. // gates (full-name / NDA) are consumed by useMutation with a continueUrl that
// still carries the marker so the request can resume after the next gate.
export function useResumeAccessRequest(isAuthenticated: boolean) { export function useResumeAccessRequest(isAuthenticated: boolean) {
const [searchParams, setSearchParams] = useSearchParams(); const [searchParams, setSearchParams] = useSearchParams();
const navigate = useNavigate();
const locale = useLocale();
const toast = Toast.useToastManager();
const { t } = useTranslation(); const { t } = useTranslation();
const firedRef = useRef(false); const firedRef = useRef(false);
const feedback = {
successMessage: t("auth.requestAccess.success"),
errorToast: t("auth.errors.requestFailed"),
};
const [requestDocumentAccess] = useMutation<useResumeAccessRequest_documentMutation>( const [requestDocumentAccess] = useMutation<useResumeAccessRequest_documentMutation>(
requestDocumentMutation, requestDocumentMutation,
{ errorToast: false }, feedback,
); );
const [requestReportAccess] = useMutation<useResumeAccessRequest_reportMutation>( const [requestReportAccess] = useMutation<useResumeAccessRequest_reportMutation>(
requestReportMutation, requestReportMutation,
{ errorToast: false }, feedback,
); );
const [requestFileAccess] = useMutation<useResumeAccessRequest_fileMutation>( const [requestFileAccess] = useMutation<useResumeAccessRequest_fileMutation>(
requestFileMutation, requestFileMutation,
{ errorToast: false }, feedback,
); );
useEffect(() => { useEffect(() => {
@@ -125,29 +124,9 @@ export function useResumeAccessRequest(isAuthenticated: boolean) {
firedRef.current = true; firedRef.current = true;
// Shared outcome handling. The full-name and NDA gates are thrown by the // Drop the marker up front so a reload can't queue a second request. The
// fetch layer, so they arrive in `onError` and deep-link to their gate page, // continueUrl passed to the mutation still includes the marker so a further
// preserving the marker so the request resumes once cleared. Other failures // full-name / NDA gate can re-queue the same resume.
// toast; success confirms.
const makeHandlers = (continueUrl: string) => ({
onCompleted: (_response: unknown, errors: PayloadError[] | null) => {
if (errors && errors.length > 0) {
toast.add({ title: t("auth.errors.requestFailed"), type: "error" });
return;
}
toast.add({ title: t("auth.requestAccess.success"), type: "success" });
},
onError: (error: Error) => {
const gatePath = gateRedirectPath(error, continueUrl, locale);
if (gatePath) {
void navigate(gatePath);
return;
}
toast.add({ title: t("auth.errors.requestFailed"), type: "error" });
},
});
// Drop the marker up front so a reload can't queue a second request.
const clear = (param: string) => { const clear = (param: string) => {
searchParams.delete(param); searchParams.delete(param);
setSearchParams(searchParams, { replace: true }); setSearchParams(searchParams, { replace: true });
@@ -156,41 +135,37 @@ export function useResumeAccessRequest(isAuthenticated: boolean) {
if (documentId) { if (documentId) {
const continueUrl = buildRequestAccessContinueUrl(REQUEST_DOCUMENT_PARAM, documentId); const continueUrl = buildRequestAccessContinueUrl(REQUEST_DOCUMENT_PARAM, documentId);
clear(REQUEST_DOCUMENT_PARAM); clear(REQUEST_DOCUMENT_PARAM);
void requestDocumentAccess({ void requestDocumentAccess(
variables: { input: { documentId } }, { variables: { input: { documentId } } },
...makeHandlers(continueUrl), { continueUrl },
}).catch(() => {}); ).catch(() => {});
return; return;
} }
if (reportId) { if (reportId) {
const continueUrl = buildRequestAccessContinueUrl(REQUEST_REPORT_PARAM, reportId); const continueUrl = buildRequestAccessContinueUrl(REQUEST_REPORT_PARAM, reportId);
clear(REQUEST_REPORT_PARAM); clear(REQUEST_REPORT_PARAM);
void requestReportAccess({ void requestReportAccess(
variables: { input: { reportId } }, { variables: { input: { reportId } } },
...makeHandlers(continueUrl), { continueUrl },
}).catch(() => {}); ).catch(() => {});
return; return;
} }
if (fileId) { if (fileId) {
const continueUrl = buildRequestAccessContinueUrl(REQUEST_FILE_PARAM, fileId); const continueUrl = buildRequestAccessContinueUrl(REQUEST_FILE_PARAM, fileId);
clear(REQUEST_FILE_PARAM); clear(REQUEST_FILE_PARAM);
void requestFileAccess({ void requestFileAccess(
variables: { input: { compliancePortalFileId: fileId } }, { variables: { input: { compliancePortalFileId: fileId } } },
...makeHandlers(continueUrl), { continueUrl },
}).catch(() => {}); ).catch(() => {});
} }
}, [ }, [
isAuthenticated, isAuthenticated,
locale,
navigate,
requestDocumentAccess, requestDocumentAccess,
requestReportAccess, requestReportAccess,
requestFileAccess, requestFileAccess,
searchParams, searchParams,
setSearchParams, setSearchParams,
t,
toast,
]); ]);
} }

View File

@@ -23,11 +23,16 @@ import { formatError, type GraphQLError } from "@probo/helpers";
import { createUseMutation, type MutationNotifier } from "@probo/relay"; import { createUseMutation, type MutationNotifier } from "@probo/relay";
import { useMemo } from "react"; import { useMemo } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router";
import { consumeAuthGate } from "#/lib/auth/continueUrl";
import { useLocale } from "#/lib/i18n/useLocale";
/** /**
* Binds the shared awaitable useMutation (`@probo/relay`) to this app's * Binds the shared awaitable useMutation (`@probo/relay`) to this app's
* feedback stack: Base UI toasts, i18next titles, and `formatError` * feedback stack: Base UI toasts, i18next titles, `formatError` descriptions,
* descriptions. This is the only place those opinions are wired. * and auth-gate redirects (sign-in / full-name / NDA). This is the only place
* those opinions are wired — every mutation gets gate handling for free.
* *
* Always import useMutation from `#/lib/relay/useMutation` — never useMutation * Always import useMutation from `#/lib/relay/useMutation` — never useMutation
* from react-relay. * from react-relay.
@@ -35,6 +40,8 @@ import { useTranslation } from "react-i18next";
function useMutationNotifier(): MutationNotifier { function useMutationNotifier(): MutationNotifier {
const toast = Toast.useToastManager(); const toast = Toast.useToastManager();
const { t } = useTranslation(); const { t } = useTranslation();
const navigate = useNavigate();
const locale = useLocale();
return useMemo<MutationNotifier>( return useMemo<MutationNotifier>(
() => ({ () => ({
@@ -49,8 +56,12 @@ function useMutationNotifier(): MutationNotifier {
type: "error", type: "error",
}); });
}, },
handleFailure: (error, continueUrl) =>
consumeAuthGate(error, continueUrl, (to) => {
void navigate(to);
}, locale),
}), }),
[toast, t], [toast, t, navigate, locale],
); );
} }

View File

@@ -18,23 +18,16 @@
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE. // SOFTWARE.
import { Toast } from "@base-ui/react/toast"; import { useCallback } from "react";
import { UnAuthenticatedError } from "@probo/relay";
import { useCallback, useMemo } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router";
import type { PayloadError } from "relay-runtime";
import { graphql } from "relay-runtime"; import { graphql } from "relay-runtime";
import { import {
buildRequestAccessContinueUrl, buildRequestAccessContinueUrl,
gateRedirectPath,
redirectToInitiate,
REQUEST_DOCUMENT_PARAM, REQUEST_DOCUMENT_PARAM,
REQUEST_FILE_PARAM, REQUEST_FILE_PARAM,
REQUEST_REPORT_PARAM, REQUEST_REPORT_PARAM,
} from "#/lib/auth/continueUrl"; } from "#/lib/auth/continueUrl";
import { useLocale } from "#/lib/i18n/useLocale";
import { useMutation } from "#/lib/relay/useMutation"; import { useMutation } from "#/lib/relay/useMutation";
import type { useAccessRequestDocumentMutation } from "./__generated__/useAccessRequestDocumentMutation.graphql"; import type { useAccessRequestDocumentMutation } from "./__generated__/useAccessRequestDocumentMutation.graphql";
@@ -94,87 +87,64 @@ const fileMutation = graphql`
} }
`; `;
// Shared success / error handling for a single access request. The auth, // Auth, full-name, and NDA gates are consumed by useMutation (with a
// full-name, and NDA gates are thrown by the fetch layer, so they surface in // marker-bearing continueUrl so the request resumes after the gate). Success
// `onError`: unauthenticated redirects to OAuth /initiate, while full-name and // and non-gate failures use the shared toast feedback.
// NDA deep-link to their gate page — all deferring the request via the continue function useAccessRequestFeedback() {
// URL so it resumes once the gate is cleared. Everything else is a generic toast.
function useAccessRequestHandlers(param: string, id: string) {
const navigate = useNavigate();
const locale = useLocale();
const toast = Toast.useToastManager();
const { t } = useTranslation(); const { t } = useTranslation();
return {
return useMemo( successMessage: t("auth.requestAccess.success"),
() => ({ errorToast: t("auth.errors.requestFailed"),
onCompleted: (_response: unknown, errors: PayloadError[] | null) => { };
if (errors && errors.length > 0) {
toast.add({ title: t("auth.errors.requestFailed"), type: "error" });
return;
}
toast.add({ title: t("auth.requestAccess.success"), type: "success" });
},
onError: (error: Error) => {
const continueUrl = buildRequestAccessContinueUrl(param, id);
// Not signed in: start OAuth, deferring this request until the user lands
// back authenticated (see useResumeAccessRequest).
if (error instanceof UnAuthenticatedError) {
redirectToInitiate(continueUrl);
return;
}
// Full-name / NDA gate: deep-link to the gate page, preserving the
// marker so the request resumes afterwards.
const gatePath = gateRedirectPath(error, continueUrl, locale);
if (gatePath) {
void navigate(gatePath);
return;
}
toast.add({ title: t("auth.errors.requestFailed"), type: "error" });
},
}),
[navigate, locale, toast, t, param, id],
);
} }
export function useRequestDocumentAccess(id: string): AccessRequest { export function useRequestDocumentAccess(id: string): AccessRequest {
const handlers = useAccessRequestHandlers(REQUEST_DOCUMENT_PARAM, id); const feedback = useAccessRequestFeedback();
const [mutate, isRequesting] = useMutation<useAccessRequestDocumentMutation>( const [mutate, isRequesting] = useMutation<useAccessRequestDocumentMutation>(
documentMutation, documentMutation,
{ errorToast: false }, feedback,
); );
const requestAccess = useCallback(() => { const requestAccess = useCallback(() => {
void mutate({ variables: { input: { documentId: id } }, ...handlers }).catch(() => {}); void mutate(
}, [mutate, id, handlers]); { variables: { input: { documentId: id } } },
{ continueUrl: buildRequestAccessContinueUrl(REQUEST_DOCUMENT_PARAM, id) },
).catch(() => {});
}, [mutate, id]);
return { requestAccess, isRequesting }; return { requestAccess, isRequesting };
} }
export function useRequestReportAccess(id: string): AccessRequest { export function useRequestReportAccess(id: string): AccessRequest {
const handlers = useAccessRequestHandlers(REQUEST_REPORT_PARAM, id); const feedback = useAccessRequestFeedback();
const [mutate, isRequesting] = useMutation<useAccessRequestReportMutation>( const [mutate, isRequesting] = useMutation<useAccessRequestReportMutation>(
reportMutation, reportMutation,
{ errorToast: false }, feedback,
); );
const requestAccess = useCallback(() => { const requestAccess = useCallback(() => {
void mutate({ variables: { input: { reportId: id } }, ...handlers }).catch(() => {}); void mutate(
}, [mutate, id, handlers]); { variables: { input: { reportId: id } } },
{ continueUrl: buildRequestAccessContinueUrl(REQUEST_REPORT_PARAM, id) },
).catch(() => {});
}, [mutate, id]);
return { requestAccess, isRequesting }; return { requestAccess, isRequesting };
} }
export function useRequestFileAccess(id: string): AccessRequest { export function useRequestFileAccess(id: string): AccessRequest {
const handlers = useAccessRequestHandlers(REQUEST_FILE_PARAM, id); const feedback = useAccessRequestFeedback();
const [mutate, isRequesting] = useMutation<useAccessRequestFileMutation>( const [mutate, isRequesting] = useMutation<useAccessRequestFileMutation>(
fileMutation, fileMutation,
{ errorToast: false }, feedback,
); );
const requestAccess = useCallback(() => { const requestAccess = useCallback(() => {
void mutate({ variables: { input: { compliancePortalFileId: id } }, ...handlers }).catch(() => {}); void mutate(
}, [mutate, id, handlers]); { variables: { input: { compliancePortalFileId: id } } },
{ continueUrl: buildRequestAccessContinueUrl(REQUEST_FILE_PARAM, id) },
).catch(() => {});
}, [mutate, id]);
return { requestAccess, isRequesting }; return { requestAccess, isRequesting };
} }

View File

@@ -18,16 +18,10 @@
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE. // SOFTWARE.
import { Toast } from "@base-ui/react/toast";
import { UnAuthenticatedError } from "@probo/relay";
import { useCallback } from "react"; import { useCallback } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router";
import type { PayloadError } from "relay-runtime";
import { graphql } from "relay-runtime"; import { graphql } from "relay-runtime";
import { gateRedirectPath, getSafeContinueUrl, redirectToInitiate } from "#/lib/auth/continueUrl";
import { useLocale } from "#/lib/i18n/useLocale";
import { useMutation } from "#/lib/relay/useMutation"; import { useMutation } from "#/lib/relay/useMutation";
import type { useBulkRequestAccessMutation } from "./__generated__/useBulkRequestAccessMutation.graphql"; import type { useBulkRequestAccessMutation } from "./__generated__/useBulkRequestAccessMutation.graphql";
@@ -78,20 +72,15 @@ export interface BulkAccessRequest {
} }
// Requests access for a mixed selection of documents / reports / files in a // Requests access for a mixed selection of documents / reports / files in a
// single mutation. Auth, full-name, and NDA gates are thrown by the fetch layer // single mutation. Auth / full-name / NDA gates are consumed by useMutation
// and surface in `onError`: unauthenticated redirects to OAuth /initiate, while // (current URL as continue — no batch marker, so the selection is not resumed
// full-name and NDA deep-link to their gate page. Unlike the single-row flow // after the gate). Success and non-gate failures use the shared toast feedback.
// this is a "simple redirect": the current URL carries no batch marker, so the
// selection is not resumed after the gate is cleared (the user re-selects).
export function useBulkRequestAccess(onSuccess?: () => void): BulkAccessRequest { export function useBulkRequestAccess(onSuccess?: () => void): BulkAccessRequest {
const navigate = useNavigate();
const locale = useLocale();
const toast = Toast.useToastManager();
const { t } = useTranslation(); const { t } = useTranslation();
const [mutate, isRequesting] = useMutation<useBulkRequestAccessMutation>( const [mutate, isRequesting] = useMutation<useBulkRequestAccessMutation>(bulkMutation, {
bulkMutation, successMessage: t("auth.requestAccess.success"),
{ errorToast: false }, errorToast: t("auth.errors.requestFailed"),
); });
const requestAccess = useCallback( const requestAccess = useCallback(
(entries: BulkAccessRequestEntry[]) => { (entries: BulkAccessRequestEntry[]) => {
@@ -115,33 +104,14 @@ export function useBulkRequestAccess(onSuccess?: () => void): BulkAccessRequest
void mutate({ void mutate({
variables: { input: { documentIds, reportIds, compliancePortalFileIds } }, variables: { input: { documentIds, reportIds, compliancePortalFileIds } },
onCompleted: (_response: unknown, errors: PayloadError[] | null) => { onCompleted: (_response, errors) => {
if (errors && errors.length > 0) { if (!errors || errors.length === 0) {
toast.add({ title: t("auth.errors.requestFailed"), type: "error" }); onSuccess?.();
return;
} }
toast.add({ title: t("auth.requestAccess.success"), type: "success" });
onSuccess?.();
},
onError: (error: Error) => {
const continueUrl = getSafeContinueUrl(window.location.href);
if (error instanceof UnAuthenticatedError) {
redirectToInitiate(continueUrl);
return;
}
const gatePath = gateRedirectPath(error, continueUrl, locale);
if (gatePath) {
void navigate(gatePath);
return;
}
toast.add({ title: t("auth.errors.requestFailed"), type: "error" });
}, },
}).catch(() => {}); }).catch(() => {});
}, },
[mutate, toast, t, navigate, locale, onSuccess], [mutate, onSuccess],
); );
return { requestAccess, isRequesting }; return { requestAccess, isRequesting };

View File

@@ -18,15 +18,9 @@
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE. // SOFTWARE.
import { Toast } from "@base-ui/react/toast";
import { UnAuthenticatedError } from "@probo/relay";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { graphql } from "react-relay"; 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 { useMutation } from "#/lib/relay/useMutation";
import type { useDocumentExportDocumentMutation } from "./__generated__/useDocumentExportDocumentMutation.graphql"; import type { useDocumentExportDocumentMutation } from "./__generated__/useDocumentExportDocumentMutation.graphql";
@@ -66,48 +60,24 @@ interface DocumentExportState {
} }
// Exports the aliased node's (watermarked) bytes for the viewer. Fires once per // Exports the aliased node's (watermarked) bytes for the viewer. Fires once per
// (kind, id) while enabled. Mutation failures cannot reach a route error // (kind, id) while enabled. Auth / full-name / NDA gates and error toasts are
// boundary, so full-name / NDA gates redirect here (same as request-access); // handled by useMutation; this hook only applies the bytes on success. Mutate
// other failures toast once. Mutate functions are read from a ref so their // functions are read from a ref so their identity churn cannot re-trigger the
// identity churn (in-flight flag, toast notifier) cannot re-trigger the effect. // effect.
export function useDocumentExport(kind: DocumentKind, id: string, enabled: boolean): DocumentExportState { export function useDocumentExport(kind: DocumentKind, id: string, enabled: boolean): DocumentExportState {
const navigate = useNavigate();
const locale = useLocale();
const toast = Toast.useToastManager();
const { t } = useTranslation();
const [exportDocument, isExportingDocument] = useMutation<useDocumentExportDocumentMutation>( const [exportDocument, isExportingDocument] = useMutation<useDocumentExportDocumentMutation>(
exportDocumentMutation, exportDocumentMutation,
{ errorToast: false },
); );
const [exportFile, isExportingFile] = useMutation<useDocumentExportFileMutation>( const [exportFile, isExportingFile] = useMutation<useDocumentExportFileMutation>(
exportFileMutation, exportFileMutation,
{ errorToast: false },
); );
const [exportReport, isExportingReport] = useMutation<useDocumentExportReportMutation>( const [exportReport, isExportingReport] = useMutation<useDocumentExportReportMutation>(
exportReportMutation, exportReportMutation,
{ errorToast: false },
); );
const latest = useRef({ const latest = useRef({ exportDocument, exportFile, exportReport });
exportDocument,
exportFile,
exportReport,
navigate,
locale,
toast,
t,
});
useEffect(() => { useEffect(() => {
latest.current = { latest.current = { exportDocument, exportFile, exportReport };
exportDocument,
exportFile,
exportReport,
navigate,
locale,
toast,
t,
};
}); });
const [dataUri, setDataUri] = useState<string | null>(null); const [dataUri, setDataUri] = useState<string | null>(null);
@@ -132,28 +102,6 @@ export function useDocumentExport(kind: DocumentKind, id: string, enabled: boole
exportReport: exportRep, exportReport: exportRep,
} = latest.current; } = 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 () => { const run = async () => {
try { try {
let data: string; let data: string;
@@ -183,8 +131,8 @@ export function useDocumentExport(kind: DocumentKind, id: string, enabled: boole
if (!cancelled) { if (!cancelled) {
setDataUri(data); setDataUri(data);
} }
} catch (error) { } catch {
handleError(error); // Gate redirects and error toasts are handled by useMutation.
} }
}; };

View File

@@ -33,10 +33,14 @@ import type {
* *
* `notifyError` receives an optional title override; when omitted, the * `notifyError` receives an optional title override; when omitted, the
* implementation supplies its own (localized) default. * implementation supplies its own (localized) default.
*
* `handleFailure` is optional. When it returns true, the failure was consumed
* (e.g. redirected to an auth / NDA gate) and `notifyError` is skipped.
*/ */
export type MutationNotifier = { export type MutationNotifier = {
notifySuccess: (message: string) => void; notifySuccess: (message: string) => void;
notifyError: (error: Error | PayloadError, title?: string) => void; notifyError: (error: Error | PayloadError, title?: string) => void;
handleFailure?: (error: Error, continueUrl: string) => boolean;
}; };
export type MutationFeedback = { export type MutationFeedback = {
@@ -46,6 +50,9 @@ export type MutationFeedback = {
// default title, a string overrides that title, and `false` disables the // default title, a string overrides that title, and `false` disables the
// automatic notification so the caller handles the rejected promise itself. // automatic notification so the caller handles the rejected promise itself.
errorToast?: boolean | string; errorToast?: boolean | string;
// Absolute URL returned to after an auth-gate redirect. Defaults to the
// current page. Pass a marker-bearing URL when a deferred action must resume.
continueUrl?: string;
}; };
/** /**
@@ -56,8 +63,8 @@ export type MutationFeedback = {
* *
* - resolves with the mutation response on success; * - resolves with the mutation response on success;
* - preserves every UseMutationConfig option by spreading the caller's config; * - preserves every UseMutationConfig option by spreading the caller's config;
* - on failure, notifies via the injected notifier (unless disabled) AND * - on failure, optionally lets `handleFailure` consume auth gates, otherwise
* rejects. * notifies via the injected notifier (unless disabled) AND rejects.
* *
* Each app calls this once with its own notifier hook and re-exports the * Each app calls this once with its own notifier hook and re-exports the
* result as the canonical `useMutation`. * result as the canonical `useMutation`.
@@ -70,12 +77,20 @@ export function createUseMutation(useNotifier: () => MutationNotifier) {
const [commit, isInFlight] = useRelayMutation<T>(mutation); const [commit, isInFlight] = useRelayMutation<T>(mutation);
const notifier = useNotifier(); const notifier = useNotifier();
const { successMessage: baseSuccess, errorToast: baseErrorToast = true } = feedback ?? {}; const {
successMessage: baseSuccess,
errorToast: baseErrorToast = true,
continueUrl: baseContinueUrl,
} = feedback ?? {};
const mutate = useCallback( const mutate = useCallback(
(config: UseMutationConfig<T>, overrides?: MutationFeedback): Promise<T["response"]> => { (config: UseMutationConfig<T>, overrides?: MutationFeedback): Promise<T["response"]> => {
const successMessage = overrides?.successMessage ?? baseSuccess; const successMessage = overrides?.successMessage ?? baseSuccess;
const errorToast = overrides?.errorToast ?? baseErrorToast; const errorToast = overrides?.errorToast ?? baseErrorToast;
const continueUrl =
overrides?.continueUrl
?? baseContinueUrl
?? (typeof window !== "undefined" ? window.location.href : "");
function notifyError(error: Error | PayloadError) { function notifyError(error: Error | PayloadError) {
if (errorToast === false) { if (errorToast === false) {
@@ -91,6 +106,10 @@ export function createUseMutation(useNotifier: () => MutationNotifier) {
return value instanceof Error ? value : new Error(String(value)); return value instanceof Error ? value : new Error(String(value));
} }
function consumeFailure(error: Error): boolean {
return notifier.handleFailure?.(error, continueUrl) === true;
}
return new Promise<T["response"]>((resolve, reject) => { return new Promise<T["response"]>((resolve, reject) => {
commit({ commit({
...config, ...config,
@@ -101,18 +120,22 @@ export function createUseMutation(useNotifier: () => MutationNotifier) {
config.onCompleted?.(response, errors); config.onCompleted?.(response, errors);
} catch (callbackError) { } catch (callbackError) {
const error = toError(callbackError); const error = toError(callbackError);
notifyError(error); if (!consumeFailure(error)) {
notifyError(error);
}
reject(error); reject(error);
return; return;
} }
if (errors && errors.length > 0) { if (errors && errors.length > 0) {
const [payloadError] = errors; const [payloadError] = errors;
notifyError(payloadError); const error =
reject(
payloadError instanceof Error payloadError instanceof Error
? payloadError ? payloadError
: new Error(payloadError.message), : new Error(payloadError.message);
); if (!consumeFailure(error)) {
notifyError(payloadError);
}
reject(error);
return; return;
} }
if (successMessage) { if (successMessage) {
@@ -121,6 +144,12 @@ export function createUseMutation(useNotifier: () => MutationNotifier) {
resolve(response); resolve(response);
}, },
onError: (error) => { onError: (error) => {
// Auth / NDA gates are consumed before the caller's onError so
// every mutation redirects consistently without per-call boilerplate.
if (consumeFailure(error)) {
reject(error);
return;
}
// Swallow a throwing caller callback so the original mutation error // Swallow a throwing caller callback so the original mutation error
// still flows through to the notifier and the rejection. // still flows through to the notifier and the rejection.
try { try {
@@ -134,7 +163,7 @@ export function createUseMutation(useNotifier: () => MutationNotifier) {
}); });
}); });
}, },
[commit, notifier, baseSuccess, baseErrorToast], [commit, notifier, baseSuccess, baseErrorToast, baseContinueUrl],
); );
return [mutate, isInFlight] as const; return [mutate, isInFlight] as const;