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
|
// 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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,
|
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
|||||||
@@ -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.
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user