Handle auth gates in every portal mutation

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

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

View File

@@ -18,7 +18,11 @@
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// 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;
}

View File

@@ -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,
]);
}

View File

@@ -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],
);
}

View File

@@ -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 };
}

View File

@@ -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 };

View File

@@ -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.
}
};

View File

@@ -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;