Press n or j to go to the next uncovered block, b, p or k for the previous block.
| 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 | 3x 28x 107x 3x 48x 3x 17x 17x 16x 3x 13x 3x 103x 103x 103x 103x 20x 19x 18x 103x 103x 103x 103x 59x 22x 103x 103x 23x 1x 1x 103x 103x 8x 4x 1x 1x 3x 3x 3x 3x 4x 3x 103x 8x 8x 9x 8x 103x | import { useEffect, useMemo, useRef } from "react";
import { SubmitHandler, useForm } from "react-hook-form";
import { AxiosError } from "axios";
import Cookies from "js-cookie";
import { useMutation, useQuery, useQueryClient } from "react-query";
import { customInstance } from "@services/api";
import { useAppSelector } from "@redux/hooks";
import { selectSelectedAccount } from "@redux/slices/auth/accounts";
import { QKEY_GET_SECURITY_CONFIGURATION } from "@constants/queryKeys";
import { showMessage } from "@common/Toast/ShowToast";
import { GENERAL_STALE_TIME } from "@features/Merchants/MerchantSidePanel/constants";
import { GlobalSecurityFormValues, SecurityConfiguration } from "../types";
import { SAVE_ERROR_MESSAGE, SAVE_SUCCESS_MESSAGE } from "../constants";
type SaveError = { message?: string; code?: string };
type SavePayload = {
acquirerId: number;
values: Partial<GlobalSecurityFormValues>;
};
const securityConfigurationUrl = (acquirerId: number) =>
`/merchants/${acquirerId}/security-configuration`;
const configurationKey = (acquirerId?: number | null) => [
QKEY_GET_SECURITY_CONFIGURATION,
acquirerId,
];
const toFormValues = (
configuration?: SecurityConfiguration,
): GlobalSecurityFormValues => ({
requirePurchaseVerification: !!configuration?.requirePurchaseVerification,
requireEmailVerification: !!configuration?.requireEmailVerification,
});
// Mirrors what the interceptor reports on its own. Each arm repeats that
// branch's conditions exactly: claiming one it does not actually take leaves
// the failure on no screen at all. ACM's "access_denied" is not among them.
export const isReportedByInterceptor = (error: AxiosError<SaveError>) => {
const status = error?.response?.status;
if (status === 400) return true;
// The interceptor's 401 branch is keyed on the code too, so a 401 without it
// reaches neither the session modal nor a redirect.
if (status === 401)
return error?.response?.data?.code === "not_authenticated";
return (
status === 403 &&
error?.response?.data?.code === "not_authorized" &&
!!Cookies.get("user")
);
};
export const useGlobalSecuritySettings = ({
isReadAllowed = true,
}: { isReadAllowed?: boolean } = {}) => {
// These controls belong to the signed-in acquirer, so this deliberately does
// not go through useGetCurrentMerchantId: that resolves to the masqueraded
// account, which only inherits the configuration and cannot save it.
const acquirerId = useAppSelector(selectSelectedAccount)?.id;
const queryClient = useQueryClient();
const queryKey = configurationKey(acquirerId);
const { data, isLoading, isError } = useQuery<SecurityConfiguration>(
queryKey,
async () => {
// customInstance resolves a canceled request as null, which would cache
// as a success and render both controls as a confident "off" -- the same
// silent-off state the isLoading guard below exists to prevent. The save
// already guards its own null; this is the read half of that.
const configuration = await customInstance({
url: securityConfigurationUrl(acquirerId as number),
});
if (!configuration) throw new Error("security configuration unavailable");
return configuration;
},
{
// A denied read is a guaranteed 403, and the section shows the lock
// screen for it regardless.
enabled: !!acquirerId && isReadAllowed,
staleTime: GENERAL_STALE_TIME,
// Nothing here is transient -- a denial, a missing account, a null body.
// The default three retries only delay the error behind a long spinner.
retry: false,
},
);
const defaultValues = useMemo(() => toFormValues(data), [data]);
const methods = useForm<GlobalSecurityFormValues>({ defaultValues });
const {
reset,
formState: { isDirty, dirtyFields },
} = methods;
// RHF applies defaultValues once, so the async response has to be pushed in.
useEffect(() => {
if (isLoading || !data || isDirty) return;
reset(defaultValues);
}, [data, defaultValues, isDirty, isLoading, reset]);
// Switching account re-points the query and the save URL, so an edit still
// pending belongs to the previous account and must not carry over.
const loadedAcquirerId = useRef(acquirerId);
useEffect(() => {
if (loadedAcquirerId.current === acquirerId) return;
loadedAcquirerId.current = acquirerId;
reset(
toFormValues(
queryClient.getQueryData<SecurityConfiguration>(
configurationKey(acquirerId),
),
),
);
}, [acquirerId, queryClient, reset]);
const reportSaveFailure = () => showMessage("Error", SAVE_ERROR_MESSAGE);
const { mutate, isLoading: isSaving } = useMutation(
({ acquirerId: id, values }: SavePayload) =>
customInstance({
url: securityConfigurationUrl(id),
method: "PATCH",
data: values,
}),
{
// The response is the configuration in force, so seed the cache with it
// rather than invalidating — a refetch would race the reset below and
// re-hydrate the form from the pre-save values. Both are keyed on the id
// the save was issued for, since the selected account may have changed
// while the request was in flight.
onSuccess: (saved: SecurityConfiguration | null, { acquirerId: id }) => {
// customInstance resolves aborted and canceled writes as null, which
// would otherwise read as a save that silently did nothing.
if (!saved) {
reportSaveFailure();
return;
}
queryClient.setQueryData(configurationKey(id), saved);
Iif (id !== loadedAcquirerId.current) return;
reset(toFormValues(saved));
showMessage("Success", SAVE_SUCCESS_MESSAGE);
},
onError: (error: AxiosError<SaveError>) => {
if (isReportedByInterceptor(error)) return;
// The API's own message names the actual problem; the generic copy is
// only for failures that arrive without one.
showMessage(
"Error",
error?.response?.data?.message || SAVE_ERROR_MESSAGE,
);
},
},
);
// The endpoint keeps an omitted control at its current value, so sending only
// the dirty ones avoids clobbering a change made by another acquirer user.
const handleSave: SubmitHandler<GlobalSecurityFormValues> = (values) => {
Iif (!acquirerId) return;
const payload = (
Object.keys(dirtyFields) as (keyof GlobalSecurityFormValues)[]
).reduce<Partial<GlobalSecurityFormValues>>(
(acc, key) => ({ ...acc, [key]: values[key] }),
{},
);
mutate({ acquirerId, values: payload });
};
return {
methods,
handleSave,
// The query is disabled without both, so isLoading is already false then.
isLoading: !acquirerId || isLoading,
isError,
isSaving,
};
};
|