From c96bf22e1c3fc2a3ee6c7459ded21487f8c49479 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89mile=20R=C3=A9?= Date: Thu, 30 Jul 2026 10:08:51 +0200 Subject: [PATCH] Handle auth gates in every portal mutation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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é --- .../src/lib/auth/continueUrl.ts | 35 +++++++- .../src/lib/auth/useResumeAccessRequest.ts | 79 ++++++---------- .../src/lib/relay/useMutation.ts | 17 +++- .../pages/documents/_lib/useAccessRequest.ts | 90 +++++++------------ .../documents/_lib/useBulkRequestAccess.ts | 52 +++-------- .../pages/documents/_lib/useDocumentExport.ts | 68 ++------------ packages/relay/src/useMutation.ts | 47 ++++++++-- 7 files changed, 160 insertions(+), 228 deletions(-) diff --git a/apps/compliance-portal/src/lib/auth/continueUrl.ts b/apps/compliance-portal/src/lib/auth/continueUrl.ts index f5b19ef18..a1adf5828 100644 --- a/apps/compliance-portal/src/lib/auth/continueUrl.ts +++ b/apps/compliance-portal/src/lib/auth/continueUrl.ts @@ -18,7 +18,11 @@ // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE // SOFTWARE. -import { FullNameRequiredError, NDASignatureRequiredError } from "@probo/relay"; +import { + FullNameRequiredError, + NDASignatureRequiredError, + UnAuthenticatedError, +} from "@probo/relay"; 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 // given `continueUrl` so the user returns here (and any deferred request // 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. +// by the route boundaries and consumeAuthGate (useMutation) 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); } @@ -115,3 +119,28 @@ export function redirectToInitiate(continueTo: string): void { initiateURL.searchParams.set("continue", getSafeContinueUrl(continueTo)); 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; +} diff --git a/apps/compliance-portal/src/lib/auth/useResumeAccessRequest.ts b/apps/compliance-portal/src/lib/auth/useResumeAccessRequest.ts index 8adfd3d85..1c2d8e80d 100644 --- a/apps/compliance-portal/src/lib/auth/useResumeAccessRequest.ts +++ b/apps/compliance-portal/src/lib/auth/useResumeAccessRequest.ts @@ -18,21 +18,17 @@ // 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 { useEffect, useRef } from "react"; import { useTranslation } from "react-i18next"; -import { useNavigate, useSearchParams } from "react-router"; -import type { PayloadError } from "relay-runtime"; +import { useSearchParams } from "react-router"; import { graphql } from "relay-runtime"; import { buildRequestAccessContinueUrl, - gateRedirectPath, REQUEST_DOCUMENT_PARAM, REQUEST_FILE_PARAM, REQUEST_REPORT_PARAM, } from "#/lib/auth/continueUrl"; -import { useLocale } from "#/lib/i18n/useLocale"; import { useMutation } from "#/lib/relay/useMutation"; 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 // carried a deferred access marker. This hook fires the matching mutation once // (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 -// clears the marker so a refresh never re-triggers it. +// locked row — and clears the marker so a refresh never re-triggers it. Auth +// 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) { const [searchParams, setSearchParams] = useSearchParams(); - const navigate = useNavigate(); - const locale = useLocale(); - const toast = Toast.useToastManager(); const { t } = useTranslation(); const firedRef = useRef(false); + const feedback = { + successMessage: t("auth.requestAccess.success"), + errorToast: t("auth.errors.requestFailed"), + }; + const [requestDocumentAccess] = useMutation( requestDocumentMutation, - { errorToast: false }, + feedback, ); const [requestReportAccess] = useMutation( requestReportMutation, - { errorToast: false }, + feedback, ); const [requestFileAccess] = useMutation( requestFileMutation, - { errorToast: false }, + feedback, ); useEffect(() => { @@ -125,29 +124,9 @@ export function useResumeAccessRequest(isAuthenticated: boolean) { firedRef.current = true; - // Shared outcome handling. The full-name and NDA gates are thrown by the - // fetch layer, so they arrive in `onError` and deep-link to their gate page, - // preserving the marker so the request resumes once cleared. Other failures - // 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. + // Drop the marker up front so a reload can't queue a second request. The + // continueUrl passed to the mutation still includes the marker so a further + // full-name / NDA gate can re-queue the same resume. const clear = (param: string) => { searchParams.delete(param); setSearchParams(searchParams, { replace: true }); @@ -156,41 +135,37 @@ export function useResumeAccessRequest(isAuthenticated: boolean) { if (documentId) { const continueUrl = buildRequestAccessContinueUrl(REQUEST_DOCUMENT_PARAM, documentId); clear(REQUEST_DOCUMENT_PARAM); - void requestDocumentAccess({ - variables: { input: { documentId } }, - ...makeHandlers(continueUrl), - }).catch(() => {}); + void requestDocumentAccess( + { variables: { input: { documentId } } }, + { continueUrl }, + ).catch(() => {}); return; } if (reportId) { const continueUrl = buildRequestAccessContinueUrl(REQUEST_REPORT_PARAM, reportId); clear(REQUEST_REPORT_PARAM); - void requestReportAccess({ - variables: { input: { reportId } }, - ...makeHandlers(continueUrl), - }).catch(() => {}); + void requestReportAccess( + { variables: { input: { reportId } } }, + { continueUrl }, + ).catch(() => {}); return; } if (fileId) { const continueUrl = buildRequestAccessContinueUrl(REQUEST_FILE_PARAM, fileId); clear(REQUEST_FILE_PARAM); - void requestFileAccess({ - variables: { input: { compliancePortalFileId: fileId } }, - ...makeHandlers(continueUrl), - }).catch(() => {}); + void requestFileAccess( + { variables: { input: { compliancePortalFileId: fileId } } }, + { continueUrl }, + ).catch(() => {}); } }, [ isAuthenticated, - locale, - navigate, requestDocumentAccess, requestReportAccess, requestFileAccess, searchParams, setSearchParams, - t, - toast, ]); } diff --git a/apps/compliance-portal/src/lib/relay/useMutation.ts b/apps/compliance-portal/src/lib/relay/useMutation.ts index 077ac7dec..22c5ec775 100644 --- a/apps/compliance-portal/src/lib/relay/useMutation.ts +++ b/apps/compliance-portal/src/lib/relay/useMutation.ts @@ -23,11 +23,16 @@ import { formatError, type GraphQLError } from "@probo/helpers"; import { createUseMutation, type MutationNotifier } from "@probo/relay"; import { useMemo } from "react"; 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 - * feedback stack: Base UI toasts, i18next titles, and `formatError` - * descriptions. This is the only place those opinions are wired. + * feedback stack: Base UI toasts, i18next titles, `formatError` descriptions, + * 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 * from react-relay. @@ -35,6 +40,8 @@ import { useTranslation } from "react-i18next"; function useMutationNotifier(): MutationNotifier { const toast = Toast.useToastManager(); const { t } = useTranslation(); + const navigate = useNavigate(); + const locale = useLocale(); return useMemo( () => ({ @@ -49,8 +56,12 @@ function useMutationNotifier(): MutationNotifier { type: "error", }); }, + handleFailure: (error, continueUrl) => + consumeAuthGate(error, continueUrl, (to) => { + void navigate(to); + }, locale), }), - [toast, t], + [toast, t, navigate, locale], ); } diff --git a/apps/compliance-portal/src/pages/documents/_lib/useAccessRequest.ts b/apps/compliance-portal/src/pages/documents/_lib/useAccessRequest.ts index 111d0ae0a..d359aee48 100644 --- a/apps/compliance-portal/src/pages/documents/_lib/useAccessRequest.ts +++ b/apps/compliance-portal/src/pages/documents/_lib/useAccessRequest.ts @@ -18,23 +18,16 @@ // 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 { useCallback, useMemo } from "react"; +import { useCallback } from "react"; import { useTranslation } from "react-i18next"; -import { useNavigate } from "react-router"; -import type { PayloadError } from "relay-runtime"; import { graphql } from "relay-runtime"; import { buildRequestAccessContinueUrl, - gateRedirectPath, - redirectToInitiate, REQUEST_DOCUMENT_PARAM, REQUEST_FILE_PARAM, REQUEST_REPORT_PARAM, } from "#/lib/auth/continueUrl"; -import { useLocale } from "#/lib/i18n/useLocale"; import { useMutation } from "#/lib/relay/useMutation"; 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, -// full-name, and NDA gates are thrown by the fetch layer, so they surface in -// `onError`: unauthenticated redirects to OAuth /initiate, while full-name and -// NDA deep-link to their gate page — all deferring the request via the continue -// 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(); +// Auth, full-name, and NDA gates are consumed by useMutation (with a +// marker-bearing continueUrl so the request resumes after the gate). Success +// and non-gate failures use the shared toast feedback. +function useAccessRequestFeedback() { const { t } = useTranslation(); - - return useMemo( - () => ({ - 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], - ); + return { + successMessage: t("auth.requestAccess.success"), + errorToast: t("auth.errors.requestFailed"), + }; } export function useRequestDocumentAccess(id: string): AccessRequest { - const handlers = useAccessRequestHandlers(REQUEST_DOCUMENT_PARAM, id); + const feedback = useAccessRequestFeedback(); const [mutate, isRequesting] = useMutation( documentMutation, - { errorToast: false }, + feedback, ); const requestAccess = useCallback(() => { - void mutate({ variables: { input: { documentId: id } }, ...handlers }).catch(() => {}); - }, [mutate, id, handlers]); + void mutate( + { variables: { input: { documentId: id } } }, + { continueUrl: buildRequestAccessContinueUrl(REQUEST_DOCUMENT_PARAM, id) }, + ).catch(() => {}); + }, [mutate, id]); return { requestAccess, isRequesting }; } export function useRequestReportAccess(id: string): AccessRequest { - const handlers = useAccessRequestHandlers(REQUEST_REPORT_PARAM, id); + const feedback = useAccessRequestFeedback(); const [mutate, isRequesting] = useMutation( reportMutation, - { errorToast: false }, + feedback, ); const requestAccess = useCallback(() => { - void mutate({ variables: { input: { reportId: id } }, ...handlers }).catch(() => {}); - }, [mutate, id, handlers]); + void mutate( + { variables: { input: { reportId: id } } }, + { continueUrl: buildRequestAccessContinueUrl(REQUEST_REPORT_PARAM, id) }, + ).catch(() => {}); + }, [mutate, id]); return { requestAccess, isRequesting }; } export function useRequestFileAccess(id: string): AccessRequest { - const handlers = useAccessRequestHandlers(REQUEST_FILE_PARAM, id); + const feedback = useAccessRequestFeedback(); const [mutate, isRequesting] = useMutation( fileMutation, - { errorToast: false }, + feedback, ); const requestAccess = useCallback(() => { - void mutate({ variables: { input: { compliancePortalFileId: id } }, ...handlers }).catch(() => {}); - }, [mutate, id, handlers]); + void mutate( + { variables: { input: { compliancePortalFileId: id } } }, + { continueUrl: buildRequestAccessContinueUrl(REQUEST_FILE_PARAM, id) }, + ).catch(() => {}); + }, [mutate, id]); return { requestAccess, isRequesting }; } diff --git a/apps/compliance-portal/src/pages/documents/_lib/useBulkRequestAccess.ts b/apps/compliance-portal/src/pages/documents/_lib/useBulkRequestAccess.ts index 4fcdd72bb..e42f842a4 100644 --- a/apps/compliance-portal/src/pages/documents/_lib/useBulkRequestAccess.ts +++ b/apps/compliance-portal/src/pages/documents/_lib/useBulkRequestAccess.ts @@ -18,16 +18,10 @@ // 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 { useCallback } from "react"; import { useTranslation } from "react-i18next"; -import { useNavigate } from "react-router"; -import type { PayloadError } 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 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 -// single mutation. Auth, full-name, and NDA gates are thrown by the fetch layer -// and surface in `onError`: unauthenticated redirects to OAuth /initiate, while -// full-name and NDA deep-link to their gate page. Unlike the single-row flow -// 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). +// single mutation. Auth / full-name / NDA gates are consumed by useMutation +// (current URL as continue — no batch marker, so the selection is not resumed +// after the gate). Success and non-gate failures use the shared toast feedback. export function useBulkRequestAccess(onSuccess?: () => void): BulkAccessRequest { - const navigate = useNavigate(); - const locale = useLocale(); - const toast = Toast.useToastManager(); const { t } = useTranslation(); - const [mutate, isRequesting] = useMutation( - bulkMutation, - { errorToast: false }, - ); + const [mutate, isRequesting] = useMutation(bulkMutation, { + successMessage: t("auth.requestAccess.success"), + errorToast: t("auth.errors.requestFailed"), + }); const requestAccess = useCallback( (entries: BulkAccessRequestEntry[]) => { @@ -115,33 +104,14 @@ export function useBulkRequestAccess(onSuccess?: () => void): BulkAccessRequest void mutate({ variables: { input: { documentIds, reportIds, compliancePortalFileIds } }, - onCompleted: (_response: unknown, errors: PayloadError[] | null) => { - if (errors && errors.length > 0) { - toast.add({ title: t("auth.errors.requestFailed"), type: "error" }); - return; + onCompleted: (_response, errors) => { + if (!errors || errors.length === 0) { + onSuccess?.(); } - 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(() => {}); }, - [mutate, toast, t, navigate, locale, onSuccess], + [mutate, onSuccess], ); return { requestAccess, isRequesting }; diff --git a/apps/compliance-portal/src/pages/documents/_lib/useDocumentExport.ts b/apps/compliance-portal/src/pages/documents/_lib/useDocumentExport.ts index 6e33163fe..1151c919b 100644 --- a/apps/compliance-portal/src/pages/documents/_lib/useDocumentExport.ts +++ b/apps/compliance-portal/src/pages/documents/_lib/useDocumentExport.ts @@ -18,15 +18,9 @@ // 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"; @@ -66,48 +60,24 @@ interface DocumentExportState { } // 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. +// (kind, id) while enabled. Auth / full-name / NDA gates and error toasts are +// handled by useMutation; this hook only applies the bytes on success. Mutate +// functions are read from a ref so their identity churn cannot re-trigger the +// effect. 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( 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, - }); + const latest = useRef({ exportDocument, exportFile, exportReport }); useEffect(() => { - latest.current = { - exportDocument, - exportFile, - exportReport, - navigate, - locale, - toast, - t, - }; + latest.current = { exportDocument, exportFile, exportReport }; }); const [dataUri, setDataUri] = useState(null); @@ -132,28 +102,6 @@ export function useDocumentExport(kind: DocumentKind, id: string, enabled: boole 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; @@ -183,8 +131,8 @@ export function useDocumentExport(kind: DocumentKind, id: string, enabled: boole if (!cancelled) { setDataUri(data); } - } catch (error) { - handleError(error); + } catch { + // Gate redirects and error toasts are handled by useMutation. } }; diff --git a/packages/relay/src/useMutation.ts b/packages/relay/src/useMutation.ts index f083b5281..03fd8d94c 100644 --- a/packages/relay/src/useMutation.ts +++ b/packages/relay/src/useMutation.ts @@ -33,10 +33,14 @@ import type { * * `notifyError` receives an optional title override; when omitted, the * 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 = { notifySuccess: (message: string) => void; notifyError: (error: Error | PayloadError, title?: string) => void; + handleFailure?: (error: Error, continueUrl: string) => boolean; }; export type MutationFeedback = { @@ -46,6 +50,9 @@ export type MutationFeedback = { // default title, a string overrides that title, and `false` disables the // automatic notification so the caller handles the rejected promise itself. 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; * - preserves every UseMutationConfig option by spreading the caller's config; - * - on failure, notifies via the injected notifier (unless disabled) AND - * rejects. + * - on failure, optionally lets `handleFailure` consume auth gates, otherwise + * notifies via the injected notifier (unless disabled) AND rejects. * * Each app calls this once with its own notifier hook and re-exports the * result as the canonical `useMutation`. @@ -70,12 +77,20 @@ export function createUseMutation(useNotifier: () => MutationNotifier) { const [commit, isInFlight] = useRelayMutation(mutation); const notifier = useNotifier(); - const { successMessage: baseSuccess, errorToast: baseErrorToast = true } = feedback ?? {}; + const { + successMessage: baseSuccess, + errorToast: baseErrorToast = true, + continueUrl: baseContinueUrl, + } = feedback ?? {}; const mutate = useCallback( (config: UseMutationConfig, overrides?: MutationFeedback): Promise => { const successMessage = overrides?.successMessage ?? baseSuccess; const errorToast = overrides?.errorToast ?? baseErrorToast; + const continueUrl = + overrides?.continueUrl + ?? baseContinueUrl + ?? (typeof window !== "undefined" ? window.location.href : ""); function notifyError(error: Error | PayloadError) { if (errorToast === false) { @@ -91,6 +106,10 @@ export function createUseMutation(useNotifier: () => MutationNotifier) { return value instanceof Error ? value : new Error(String(value)); } + function consumeFailure(error: Error): boolean { + return notifier.handleFailure?.(error, continueUrl) === true; + } + return new Promise((resolve, reject) => { commit({ ...config, @@ -101,18 +120,22 @@ export function createUseMutation(useNotifier: () => MutationNotifier) { config.onCompleted?.(response, errors); } catch (callbackError) { const error = toError(callbackError); - notifyError(error); + if (!consumeFailure(error)) { + notifyError(error); + } reject(error); return; } if (errors && errors.length > 0) { const [payloadError] = errors; - notifyError(payloadError); - reject( + const error = payloadError instanceof Error ? payloadError - : new Error(payloadError.message), - ); + : new Error(payloadError.message); + if (!consumeFailure(error)) { + notifyError(payloadError); + } + reject(error); return; } if (successMessage) { @@ -121,6 +144,12 @@ export function createUseMutation(useNotifier: () => MutationNotifier) { resolve(response); }, 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 // still flows through to the notifier and the rejection. try { @@ -134,7 +163,7 @@ export function createUseMutation(useNotifier: () => MutationNotifier) { }); }); }, - [commit, notifier, baseSuccess, baseErrorToast], + [commit, notifier, baseSuccess, baseErrorToast, baseContinueUrl], ); return [mutate, isInFlight] as const;