All files / src/features/AcquirerPortal/AcquirerSettings/SecuritySettings/hooks useGlobalSecuritySettings.ts

96.55% Statements 56/58
87.5% Branches 28/32
100% Functions 15/15
100% Lines 48/48

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