18F/identity-idp

View on GitHub
app/javascript/packages/document-capture/components/acuant-capture.tsx

Summary

Maintainability
A
0 mins
Test Coverage
import { Button, FullScreen } from '@18f/identity-components';
import type { MouseEvent, ReactNode, Ref } from 'react';
import {
  forwardRef,
  useContext,
  useEffect,
  useImperativeHandle,
  useMemo,
  useRef,
  useState,
} from 'react';
import type { FocusTrap } from 'focus-trap';
import type { FullScreenRefHandle } from '@18f/identity-components';
import { useDidUpdateEffect } from '@18f/identity-react-hooks';
import { useI18n } from '@18f/identity-react-i18n';
import { removeUnloadProtection } from '@18f/identity-url';
import AcuantCamera, { AcuantDocumentType } from './acuant-camera';
import AcuantSelfieCamera from './acuant-selfie-camera';
import AcuantSelfieCaptureCanvas from './acuant-selfie-capture-canvas';
import type { AcuantCaptureFailureError, AcuantSuccessResponse } from './acuant-camera';
import AcuantCaptureCanvas from './acuant-capture-canvas';
import AcuantContext, { AcuantCaptureMode } from '../context/acuant';
import AnalyticsContext from '../context/analytics';
import DeviceContext from '../context/device';
import SelfieCaptureContext from '../context/selfie-capture';
import FailedCaptureAttemptsContext from '../context/failed-capture-attempts';
import FileInput from './file-input';
import UploadContext from '../context/upload';
import useCookie from '../hooks/use-cookie';
import useCounter from '../hooks/use-counter';
import { useLogCameraInfo } from '../hooks/use-log-camera-info';

type AcuantImageAssessment = 'success' | 'glare' | 'blurry' | 'unsupported';
type ImageSource = 'acuant' | 'upload';

interface ImageAnalyticsPayload {
  /**
   * Image width, or null if unknown
   */
  width?: number | null;
  /**
   * Image height, or null if unknown
   */
  height?: number | null;
  /**
   * Mime type, or null if unknown
   */
  mimeType: string | null;
  /**
   * Method by which the image was added
   */
  source: ImageSource;
  /**
   * Total number of attempts to capture / upload an image at this point
   */
  captureAttempts?: number;
  /**
   * Size of the image in bytes
   */
  size: number;
  /**
   * Whether the Acuant SDK captured the image automatically, or using the tap to
   * capture functionality
   */
  acuantCaptureMode?: AcuantCaptureMode | null;

  /**
   * Fingerprint of the image, base64 encoded SHA-256 digest
   */
  fingerprint?: string | null;

  /**
   *
   */
  failedImageResubmission: boolean;

  /**
   * Image file name
   */
  fileName?: string;
}

interface AcuantImageAnalyticsPayload extends ImageAnalyticsPayload {
  documentType: string;
  dpi: number;
  moire: number;
  glare: number;
  glareScoreThreshold: number | null;
  isAssessedAsGlare: boolean;
  sharpness: number;
  sharpnessScoreThreshold: number | null;
  isAssessedAsBlurry: boolean;
  assessment: AcuantImageAssessment;
  isAssessedAsUnsupported: boolean;
}

interface AcuantCaptureProps {
  /**
   * Label associated with file input
   */
  label: string;
  /**
   * Optional banner text to show in file input
   */
  bannerText: string;
  /**
   * Current value
   */
  value: string | Blob | null | undefined;
  /**
   * Callback receiving next value on change
   */
  onChange: (nextValue: string | Blob | null, metadata?: ImageAnalyticsPayload) => void;
  /**
   * Camera permission declined callback
   */
  onCameraAccessDeclined?: () => void;
  /**
   * Optional additional class names
   */
  className?: string;
  /**
   * Whether to allow file upload. Defaults
   * to true.
   */
  allowUpload?: boolean;
  /**
   * Error message to show
   */
  errorMessage: ReactNode;
  /**
   * Prefix to prepend to user action analytics labels.
   */
  name: string;
}

/**
 * Non-breaking space (` `) represented as unicode escape sequence, which React will more
 * happily tolerate than an HTML entity.
 */
const NBSP_UNICODE = '\u00A0';

/**
 * A noop function.
 */
const noop = () => {};

/**
 * Returns true if the given Acuant capture failure was caused by the user declining access to the
 * camera, or false otherwise.
 */
export const isAcuantCameraAccessFailure = (error: AcuantCaptureFailureError): error is Error =>
  error instanceof Error;

/**
 * Returns a human-readable document label corresponding to the given document type constant,
 * such as "id" "passport" or "none"
 */
const getDocumentTypeLabel = (documentType: AcuantDocumentType): string =>
  AcuantDocumentType[documentType]?.toLowerCase() ??
  `An error in document type returned: ${documentType}`;

export function getNormalizedAcuantCaptureFailureMessage(
  error: AcuantCaptureFailureError,
  code: string | undefined,
): string {
  if (isAcuantCameraAccessFailure(error)) {
    return 'User or system denied camera access';
  }

  const { REPEAT_FAIL_CODE, SEQUENCE_BREAK_CODE } = window.AcuantJavascriptWebSdk;

  switch (code) {
    case REPEAT_FAIL_CODE:
      return 'Capture started after failure already occurred (REPEAT_FAIL_CODE)';
    case SEQUENCE_BREAK_CODE:
      return 'iOS 15 GPU Highwater failure (SEQUENCE_BREAK_CODE)';
    default:
  }

  if (!error) {
    return 'Cropping failure';
  }

  switch (error) {
    case 'Camera not supported.':
      return 'Camera not supported';
    case 'Missing HTML elements.':
    case "Expected div with 'acuant-camera' id":
      return 'Required page elements are not available';
    case 'already started.':
      return 'Capture already started';
    default:
      return 'Unknown error';
  }
}

function getFingerPrint(file: File): Promise<string | null> {
  return new Promise((resolve) => {
    const reader = new FileReader();
    reader.onload = () => {
      const dataBuffer = reader.result;
      window.crypto.subtle
        .digest('SHA-256', dataBuffer as ArrayBuffer)
        .then((arrayBuffer) => {
          const digestArray = new Uint8Array(arrayBuffer);
          const strDigest = digestArray.reduce(
            (data, byte) => data + String.fromCharCode(byte),
            '',
          );
          const base64String = window.btoa(strDigest);
          const urlSafeBase64String = base64String
            .replace(/\+/g, '-')
            .replace(/\//g, '_')
            .replace(/=+$/, '');
          resolve(urlSafeBase64String);
        })
        .catch(() => null);
    };
    reader.readAsArrayBuffer(file);
  });
}
function getImageDimensions(file: File): Promise<{ width: number | null; height: number | null }> {
  let objectURL: string;
  return file.type.indexOf('image/') === 0
    ? new Promise<{ width: number | null; height: number | null }>((resolve) => {
        objectURL = window.URL.createObjectURL(file);
        const image = new window.Image();
        image.onload = () => resolve({ width: image.width, height: image.height });
        image.onerror = () => resolve({ width: null, height: null });
        image.src = objectURL;
      })
        .then(({ width, height }) => {
          window.URL.revokeObjectURL(objectURL);
          return { width, height };
        })
        .catch(() => ({ width: null, height: null }))
    : Promise.resolve({ width: null, height: null });
}

function getImageMetadata(
  file: File,
): Promise<{ width: number | null; height: number | null; fingerprint: string | null }> {
  const dimension = getImageDimensions(file);
  const fingerprint = getFingerPrint(file);
  return new Promise<{ width: number | null; height: number | null; fingerprint: string | null }>(
    function (resolve) {
      Promise.all([dimension, fingerprint])
        .then((results) => {
          resolve({ width: results[0].width, height: results[0].height, fingerprint: results[1] });
        })
        .catch(() => ({ width: null, height: null, fingerprint: null }));
    },
  );
}

/**
 * Pauses default focus trap behaviors for a single tick. If a focus transition occurs during this
 * tick, the focus trap's deactivation will be overridden to prevent any default focus return, in
 * order to avoid a race condition between the intended focus targets.
 *
 */
function suspendFocusTrapForAnticipatedFocus(focusTrap: FocusTrap) {
  // Pause trap event listeners to prevent focus from being pulled back into the trap container in
  // response to programmatic focus transitions.
  focusTrap.pause();

  const originalFocus = document.activeElement;

  // If an element is focused while behaviors are suspended, prevent the default deactivate from
  // attempting to return focus to any other element.
  const originalDeactivate = focusTrap.deactivate;
  focusTrap.deactivate = (deactivateOptions) => {
    const didChangeFocus = originalFocus !== document.activeElement;
    if (didChangeFocus) {
      deactivateOptions = { ...deactivateOptions, returnFocus: false };
    }

    return originalDeactivate(deactivateOptions);
  };

  // After the current frame, assume that focus was not moved elsewhere, or at least resume original
  // trap behaviors.
  setTimeout(() => {
    focusTrap.deactivate = originalDeactivate;
    focusTrap.unpause();
  }, 0);
}

export function getDecodedBase64ByteSize(data: string) {
  let bytes = 0.75 * data.length;

  let i = data.length;
  while (data[--i] === '=') {
    bytes--;
  }

  return bytes;
}

/**
 * Returns an element serving as an enhanced FileInput, supporting direct capture using Acuant SDK
 * in supported devices.
 */
function AcuantCapture(
  {
    label,
    bannerText,
    value,
    onChange = () => {},
    onCameraAccessDeclined = () => {},
    className,
    allowUpload = true,
    errorMessage,
    name,
  }: AcuantCaptureProps,
  ref: Ref<HTMLInputElement | null>,
) {
  const {
    isReady,
    isActive: isAcuantInstanceActive,
    acuantCaptureMode,
    isError,
    isCameraSupported,
    glareThreshold,
    sharpnessThreshold,
  } = useContext(AcuantContext);
  const { isMockClient } = useContext(UploadContext);
  const { trackEvent } = useContext(AnalyticsContext);
  const { isSelfieCaptureEnabled } = useContext(SelfieCaptureContext);
  const fullScreenRef = useRef<FullScreenRefHandle>(null);
  const inputRef = useRef<HTMLInputElement>(null);
  const isForceUploading = useRef(false);
  const isSuppressingClickLogging = useRef(false);
  const [isCapturingEnvironment, setIsCapturingEnvironment] = useState(false);
  const [ownErrorMessage, setOwnErrorMessage] = useState<string | null>(null);
  const [hasStartedCropping, setHasStartedCropping] = useState(false);
  useMemo(() => setOwnErrorMessage(null), [value]);
  const { isMobile } = useContext(DeviceContext);
  const { t, formatHTML } = useI18n();
  const [captureAttempts, incrementCaptureAttempts] = useCounter(1);
  const selfieAttempts = useRef(0);
  const [acuantFailureCookie, setAcuantFailureCookie, refreshAcuantFailureCookie] =
    useCookie('AcuantCameraHasFailed');
  const [imageCaptureText, setImageCaptureText] = useState('');
  // There's some pretty significant changes to this component when it's used for
  // selfie capture vs document image capture. This controls those changes.
  const selfieCapture = name === 'selfie';
  // When it's the back of the ID we want to log information about the camera
  // This hook does that.
  const isBackOfId = name === 'back';
  useLogCameraInfo({ isBackOfId, hasStartedCropping });

  const {
    failedCaptureAttempts,
    onFailedCaptureAttempt,
    failedCameraPermissionAttempts,
    onFailedCameraPermissionAttempt,
    onResetFailedCaptureAttempts,
    failedSubmissionAttempts,
    forceNativeCamera,
    failedSubmissionImageFingerprints,
  } = useContext(FailedCaptureAttemptsContext);

  const hasCapture = !isError && (isReady ? isCameraSupported : isMobile);
  useEffect(() => {
    // If capture had started before Acuant was ready, stop capture if readiness reveals that no
    // capture is supported. This takes advantage of the fact that state setter is noop if value of
    // `isCapturing` is already false.
    if (!hasCapture) {
      setIsCapturingEnvironment(false);
    }
  }, [hasCapture]);
  useDidUpdateEffect(() => setHasStartedCropping(false), [isCapturingEnvironment]);
  useImperativeHandle(ref, () => inputRef.current);

  /**
   * Calls onChange with next value and resets any errors which may be present.
   */
  function onChangeAndResetError(
    nextValue: Blob | string | null,
    metadata?: ImageAnalyticsPayload,
  ) {
    setOwnErrorMessage(null);
    onChange(nextValue, metadata);
  }

  /**
   * Returns an analytics payload, decorated with common values.
   */
  function getAddAttemptAnalyticsPayload<
    P extends ImageAnalyticsPayload | AcuantImageAnalyticsPayload,
  >(payload: P): P {
    const enhancedPayload = {
      ...payload,
      captureAttempts,
      selfie_attempts: selfieAttempts.current,
      acuantCaptureMode: payload.source === 'upload' ? null : acuantCaptureMode,
      liveness_checking_required: isSelfieCaptureEnabled,
    };
    incrementCaptureAttempts();
    return enhancedPayload;
  }

  /**
   * Handler for file input change events.
   */
  async function onUpload(nextValue: File | null) {
    let analyticsPayload: ImageAnalyticsPayload | undefined;
    let hasFailed = false;

    if (nextValue) {
      const { width, height, fingerprint } = await getImageMetadata(nextValue);
      hasFailed = failedSubmissionImageFingerprints[name]?.includes(fingerprint);
      analyticsPayload = getAddAttemptAnalyticsPayload({
        width,
        height,
        fingerprint,
        mimeType: nextValue.type,
        source: 'upload',
        size: nextValue.size,
        failedImageResubmission: hasFailed,
        fileName: nextValue.name,
      });
      trackEvent(
        name === 'selfie' ? 'idv_selfie_image_added' : `IdV: ${name} image added`,
        analyticsPayload,
      );
    }

    onChangeAndResetError(nextValue, analyticsPayload);
  }

  /**
   * Given a clickSource, returns a higher-order function that, when called, will log an event
   * before calling the original function.
   */
  function withLoggedClick(clickSource: string, metadata: { isDrop: boolean } = { isDrop: false }) {
    return <T extends (...args: any[]) => any>(fn: T) =>
      (...args: Parameters<T>) => {
        if (!isSuppressingClickLogging.current) {
          trackEvent(
            name === 'selfie' ? 'idv_selfie_image_clicked' : `IdV: ${name} image clicked`,
            {
              click_source: clickSource,
              ...metadata,
              liveness_checking_required: isSelfieCaptureEnabled,
              captureAttempts,
            },
          );
        }

        return fn(...args);
      };
  }

  /**
   * Calls the given function, during which time any normal click logging will be suppressed.
   *
   */
  function withoutClickLogging(fn: () => any) {
    isSuppressingClickLogging.current = true;
    fn();
    isSuppressingClickLogging.current = false;
  }

  /**
   * Triggers upload to occur, regardless of support for direct capture. This is necessary since the
   * default behavior for interacting with the file input is intercepted when capture is supported.
   * Calling `forceUpload` will flag the click handling to skip intercepting the event as capture.
   */
  function forceUpload() {
    if (!inputRef.current) {
      return;
    }

    isForceUploading.current = true;

    const originalCapture = inputRef.current.getAttribute('capture');

    if (originalCapture !== null) {
      inputRef.current.removeAttribute('capture');
    }

    withoutClickLogging(() => inputRef.current?.click());

    if (originalCapture !== null) {
      inputRef.current.setAttribute('capture', originalCapture);
    }
  }

  /**
   * Responds to a click by starting capture if supported in the environment, or triggering the
   * default file picker prompt. The click event may originate from the file input itself, or
   * another element which aims to trigger the prompt of the file input.
   */
  function startCaptureOrTriggerUpload(event: MouseEvent) {
    if (event.target === inputRef.current) {
      const isAcuantCaptureCapable = hasCapture && !acuantFailureCookie;
      const shouldStartAcuantCapture =
        isAcuantCaptureCapable && !isForceUploading.current && !forceNativeCamera;

      if (isAcuantCaptureCapable && forceNativeCamera) {
        trackEvent('IdV: Native camera forced after failed attempts', {
          field: name,
          failed_capture_attempts: failedCaptureAttempts,
          failed_submission_attempts: failedSubmissionAttempts,
        });
      }

      if (!allowUpload || shouldStartAcuantCapture) {
        event.preventDefault();
      }

      if (shouldStartAcuantCapture && !isAcuantInstanceActive) {
        setIsCapturingEnvironment(true);
      }

      isForceUploading.current = false;
    } else {
      withoutClickLogging(() => inputRef.current?.click());
    }
  }

  function onSelfieCaptureOpen() {
    trackEvent('idv_sdk_selfie_image_capture_opened', {
      captureAttempts,
      selfie_attempts: selfieAttempts.current,
    });

    setImageCaptureText('');
  }

  function onSelfieCaptureClosed() {
    trackEvent('idv_sdk_selfie_image_capture_closed_without_photo', {
      captureAttempts,
      selfie_attempts: selfieAttempts.current,
    });

    setImageCaptureText('');
    setIsCapturingEnvironment(false);
  }

  function onSelfieCaptureSuccess({ image }: { image: string }) {
    const analyticsPayload: ImageAnalyticsPayload = getAddAttemptAnalyticsPayload({
      mimeType: 'image/jpeg', // Acuant Web SDK currently encodes all images as JPEG
      source: 'acuant',
      size: getDecodedBase64ByteSize(image),
      failedImageResubmission: false,
    });

    trackEvent('idv_selfie_image_added', {
      captureAttempts,
      selfie_attempts: selfieAttempts.current,
    });

    onChangeAndResetError(image, analyticsPayload);
    onResetFailedCaptureAttempts();
    setIsCapturingEnvironment(false);
  }

  function onSelfieCaptureFailure(error) {
    trackEvent('idv_sdk_selfie_image_capture_failed', {
      sdk_error_code: error.code,
      sdk_error_message: error.message,
      captureAttempts,
      selfie_attempts: selfieAttempts.current,
    });

    if (fullScreenRef.current?.focusTrap) {
      suspendFocusTrapForAnticipatedFocus(fullScreenRef.current.focusTrap);
    }

    // Internally, Acuant sets a cookie to bail on guided capture if initialization had
    // previously failed for any reason, including declined permission. Since the cookie
    // never expires, and since we want to re-prompt even if the user had previously
    // declined, unset the cookie value when failure occurs for permissions.
    setAcuantFailureCookie(null);
    onCameraAccessDeclined();

    // Due to a bug with Safari on iOS we force the page to refresh on the third
    // time a user denies permissions.
    onFailedCameraPermissionAttempt();
    if (failedCameraPermissionAttempts > 2) {
      removeUnloadProtection();
      window.location.reload();
    }
    setIsCapturingEnvironment(false);
  }

  function onSelfieRetaken() {
    trackEvent('idv_sdk_selfie_image_re_taken', {
      captureAttempts,
      selfie_attempts: selfieAttempts.current,
    });
  }

  function onAcuantImageCaptureSuccess(nextCapture: AcuantSuccessResponse) {
    const { image, dpi, moire, glare, sharpness, cardType } = nextCapture;

    const isAssessedAsGlare = !!glareThreshold && glare < glareThreshold;
    const isAssessedAsBlurry = !!sharpnessThreshold && sharpness < sharpnessThreshold;
    const isAssessedAsUnsupported = cardType !== AcuantDocumentType.ID;
    const { width, height, data } = image;

    let assessment: AcuantImageAssessment;
    if (isAssessedAsBlurry) {
      setOwnErrorMessage(t('doc_auth.errors.sharpness.failed_short'));
      assessment = 'blurry';
    } else if (isAssessedAsGlare) {
      setOwnErrorMessage(t('doc_auth.errors.glare.failed_short'));
      assessment = 'glare';
    } else if (isAssessedAsUnsupported) {
      setOwnErrorMessage(t('doc_auth.errors.card_type'));
      assessment = 'unsupported';
    } else {
      assessment = 'success';
    }

    const analyticsPayload: AcuantImageAnalyticsPayload = getAddAttemptAnalyticsPayload({
      width,
      height,
      mimeType: 'image/jpeg', // Acuant Web SDK currently encodes all images as JPEG
      source: 'acuant',
      isAssessedAsUnsupported,
      documentType: getDocumentTypeLabel(cardType),
      dpi,
      moire,
      glare,
      glareScoreThreshold: glareThreshold,
      isAssessedAsGlare,
      sharpness,
      sharpnessScoreThreshold: sharpnessThreshold,
      isAssessedAsBlurry,
      assessment,
      size: getDecodedBase64ByteSize(nextCapture.image.data),
      fingerprint: null,
      failedImageResubmission: false,
      liveness_checking_required: false,
      selfie_attempts: selfieAttempts.current,
    });

    trackEvent(
      name === 'selfie' ? 'idv_selfie_image_added' : `IdV: ${name} image added`,
      analyticsPayload,
    );

    if (assessment === 'success') {
      onChangeAndResetError(data, analyticsPayload);
      onResetFailedCaptureAttempts();
    } else {
      onFailedCaptureAttempt({
        isAssessedAsGlare,
        isAssessedAsBlurry,
        isAssessedAsUnsupported,
      });
    }

    setIsCapturingEnvironment(false);
  }

  function onAcuantImageCaptureFailure(error: AcuantCaptureFailureError, code: string | undefined) {
    const { SEQUENCE_BREAK_CODE } = window.AcuantJavascriptWebSdk;
    if (isAcuantCameraAccessFailure(error)) {
      if (fullScreenRef.current?.focusTrap) {
        suspendFocusTrapForAnticipatedFocus(fullScreenRef.current.focusTrap);
      }

      // Internally, Acuant sets a cookie to bail on guided capture if initialization had
      // previously failed for any reason, including declined permission. Since the cookie
      // never expires, and since we want to re-prompt even if the user had previously
      // declined, unset the cookie value when failure occurs for permissions.
      setAcuantFailureCookie(null);

      onCameraAccessDeclined();

      // Due to a bug with Safari on iOS we force the page to refresh on the third
      // time a user denies permissions.
      onFailedCameraPermissionAttempt();
      if (failedCameraPermissionAttempts > 2) {
        removeUnloadProtection();
        window.location.reload();
      }
    } else if (code === SEQUENCE_BREAK_CODE) {
      setOwnErrorMessage(
        `${t('doc_auth.errors.upload_error')} ${t('errors.messages.try_again')
          .split(' ')
          .join(NBSP_UNICODE)}`,
      );

      refreshAcuantFailureCookie();
    } else if (error === undefined) {
      // Show a more generic error message when there's a cropping error.
      // Errors with a value of `undefined` are cropping errors.
      setOwnErrorMessage(t('errors.general'));
    } else {
      setOwnErrorMessage(t('doc_auth.errors.camera.failed'));
    }

    setIsCapturingEnvironment(false);
    trackEvent('IdV: Image capture failed', {
      field: name,
      acuantCaptureMode,
      error: getNormalizedAcuantCaptureFailureMessage(error, code),
      liveness_checking_required: isSelfieCaptureEnabled,
    });
  }

  function onImageCaptureFeedback(text: string) {
    setImageCaptureText(text);
  }

  function onSelfieTaken() {
    selfieAttempts.current += 1;
    trackEvent('idv_sdk_selfie_image_taken', {
      captureAttempts,
      selfie_attempts: selfieAttempts.current,
    });
  }

  function onImageCaptureInitialized() {
    trackEvent('idv_sdk_selfie_image_capture_initialized', {
      captureAttempts,
      selfie_attempts: selfieAttempts.current,
    });
  }

  return (
    <div className={[className, 'document-capture-acuant-capture'].filter(Boolean).join(' ')}>
      {isCapturingEnvironment && !selfieCapture && (
        <AcuantCamera
          onCropStart={() => setHasStartedCropping(true)}
          onImageCaptureSuccess={onAcuantImageCaptureSuccess}
          onImageCaptureFailure={onAcuantImageCaptureFailure}
        >
          {!hasStartedCropping && (
            <FullScreen
              ref={fullScreenRef}
              label={t('doc_auth.accessible_labels.document_capture_dialog')}
              onRequestClose={() => setIsCapturingEnvironment(false)}
            >
              <AcuantCaptureCanvas />
            </FullScreen>
          )}
        </AcuantCamera>
      )}
      {isCapturingEnvironment && selfieCapture && (
        <AcuantSelfieCamera
          onImageCaptureSuccess={onSelfieCaptureSuccess}
          onImageCaptureFailure={onSelfieCaptureFailure}
          onImageCaptureOpen={onSelfieCaptureOpen}
          onImageCaptureClose={onSelfieCaptureClosed}
          onImageCaptureFeedback={onImageCaptureFeedback}
          onImageCaptureInitialized={onImageCaptureInitialized}
          onSelfieTaken={onSelfieTaken}
          onSelfieRetaken={onSelfieRetaken}
        >
          <FullScreen
            ref={fullScreenRef}
            label={t('doc_auth.accessible_labels.document_capture_dialog')}
            hideCloseButton
          >
            <AcuantSelfieCaptureCanvas
              imageCaptureText={imageCaptureText}
              onSelfieCaptureClosed={onSelfieCaptureClosed}
            />
          </FullScreen>
        </AcuantSelfieCamera>
      )}
      <FileInput
        ref={inputRef}
        label={label}
        hint={hasCapture || !allowUpload ? undefined : t('doc_auth.tips.document_capture_hint')}
        bannerText={bannerText}
        invalidTypeText={t('doc_auth.errors.file_type.invalid')}
        fileUpdatedText={t('doc_auth.info.image_updated')}
        fileLoadingText={t('doc_auth.info.image_loading')}
        fileLoadedText={t('doc_auth.info.image_loaded')}
        accept={isMockClient ? undefined : ['image/jpeg', 'image/png']}
        value={value}
        errorMessage={ownErrorMessage ?? errorMessage}
        isValuePending={hasStartedCropping}
        onClick={withLoggedClick('placeholder')(startCaptureOrTriggerUpload)}
        onDrop={withLoggedClick('placeholder', { isDrop: true })(noop)}
        onChange={onUpload}
        onError={() => setOwnErrorMessage(null)}
      />
      <div className="margin-top-2">
        {isMobile && (
          <Button
            isFlexibleWidth
            isOutline={!value}
            isUnstyled={!!value}
            onClick={withLoggedClick('button')(startCaptureOrTriggerUpload)}
            className={value ? 'margin-right-1' : 'margin-right-2'}
          >
            {(hasCapture || !allowUpload) &&
              (value
                ? t('doc_auth.buttons.take_picture_retry')
                : t('doc_auth.buttons.take_picture'))}
            {!hasCapture && allowUpload && t('doc_auth.buttons.upload_picture')}
          </Button>
        )}
        {isMobile &&
          hasCapture &&
          allowUpload &&
          formatHTML(t('doc_auth.buttons.take_or_upload_picture_html'), {
            'lg-take-photo': () => null,
            'lg-or': ({ children }) => (
              <span className="padding-left-1 padding-right-1">{children}</span>
            ),
            'lg-upload': ({ children }) => (
              <Button isUnstyled onClick={withLoggedClick('button')(forceUpload)}>
                {children}
              </Button>
            ),
          })}
      </div>
    </div>
  );
}

export default forwardRef(AcuantCapture);
export { AcuantDocumentType };