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:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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<useResumeAccessRequest_documentMutation>(
|
||||
requestDocumentMutation,
|
||||
{ errorToast: false },
|
||||
feedback,
|
||||
);
|
||||
const [requestReportAccess] = useMutation<useResumeAccessRequest_reportMutation>(
|
||||
requestReportMutation,
|
||||
{ errorToast: false },
|
||||
feedback,
|
||||
);
|
||||
const [requestFileAccess] = useMutation<useResumeAccessRequest_fileMutation>(
|
||||
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,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -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<MutationNotifier>(
|
||||
() => ({
|
||||
@@ -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],
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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<useAccessRequestDocumentMutation>(
|
||||
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<useAccessRequestReportMutation>(
|
||||
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<useAccessRequestFileMutation>(
|
||||
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 };
|
||||
}
|
||||
|
||||
@@ -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<useBulkRequestAccessMutation>(
|
||||
bulkMutation,
|
||||
{ errorToast: false },
|
||||
);
|
||||
const [mutate, isRequesting] = useMutation<useBulkRequestAccessMutation>(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 };
|
||||
|
||||
@@ -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<useDocumentExportDocumentMutation>(
|
||||
exportDocumentMutation,
|
||||
{ errorToast: false },
|
||||
);
|
||||
const [exportFile, isExportingFile] = useMutation<useDocumentExportFileMutation>(
|
||||
exportFileMutation,
|
||||
{ errorToast: false },
|
||||
);
|
||||
const [exportReport, isExportingReport] = useMutation<useDocumentExportReportMutation>(
|
||||
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<string | null>(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.
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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<T>(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<T>, overrides?: MutationFeedback): Promise<T["response"]> => {
|
||||
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<T["response"]>((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;
|
||||
|
||||
Reference in New Issue
Block a user