diff --git a/src/components/Global/Modal/index.tsx b/src/components/Global/Modal/index.tsx index 0764613c6..6a7f3f536 100644 --- a/src/components/Global/Modal/index.tsx +++ b/src/components/Global/Modal/index.tsx @@ -77,7 +77,7 @@ const Modal = ({ > diff --git a/src/components/Kyc/CameraPermissionWarningModal.tsx b/src/components/Kyc/CameraPermissionWarningModal.tsx new file mode 100644 index 000000000..8adf071d4 --- /dev/null +++ b/src/components/Kyc/CameraPermissionWarningModal.tsx @@ -0,0 +1,79 @@ +'use client' + +import ActionModal, { type ActionModalButtonProps } from '@/components/Global/ActionModal' +import { type IconName } from '@/components/Global/Icons/Icon' +import type { MediaCheckResult } from '@/utils/mediaPermissions.utils' + +interface CameraPermissionWarningModalProps { + visible: boolean + onClose: () => void + onContinueAnyway: () => void + onOpenInBrowser: () => void + mediaCheckResult: MediaCheckResult +} + +/** + * Modal that warns users when camera/microphone access is likely to fail + * Offers two options: try anyway in iframe, or open in browser + */ +export default function CameraPermissionWarningModal({ + visible, + onClose, + onContinueAnyway, + onOpenInBrowser, + mediaCheckResult, +}: CameraPermissionWarningModalProps) { + const isError = mediaCheckResult.severity === 'error' + + const getTitle = () => { + if (isError) { + return 'Camera or Microphone Required' + } + return 'Camera Access May Be Limited' + } + + const getDescription = () => { + if (mediaCheckResult.message) { + return mediaCheckResult.message + } + return 'Identity verification requires camera and microphone access. Opening in your browser may provide better results.' + } + + const ctas: ActionModalButtonProps[] = [ + { + text: 'Open in Browser', + icon: 'arrow-up-right' as IconName, + iconPosition: 'right', + onClick: onOpenInBrowser, + variant: 'purple', + shadowSize: '4', + className: 'justify-center', + }, + ] + + // Only show "Try Anyway" if it's a warning (not a hard error) + if (!isError) { + ctas.push({ + text: 'Try Anyway', + onClick: onContinueAnyway, + variant: 'transparent', + className: 'underline text-xs md:text-sm !font-normal !transform-none !pt-2 text-grey-1', + }) + } + + return ( + + ) +} diff --git a/src/components/Kyc/InitiateBridgeKYCModal.tsx b/src/components/Kyc/InitiateBridgeKYCModal.tsx index 8fbca218c..51e7db29a 100644 --- a/src/components/Kyc/InitiateBridgeKYCModal.tsx +++ b/src/components/Kyc/InitiateBridgeKYCModal.tsx @@ -2,9 +2,11 @@ import ActionModal from '@/components/Global/ActionModal' import { useBridgeKycFlow } from '@/hooks/useBridgeKycFlow' import IframeWrapper from '@/components/Global/IframeWrapper' import { KycVerificationInProgressModal } from './KycVerificationInProgressModal' +import CameraPermissionWarningModal from './CameraPermissionWarningModal' import { type IconName } from '@/components/Global/Icons/Icon' import { saveRedirectUrl } from '@/utils' import useClaimLink from '../Claim/useClaimLink' +import { useKycCameraCheck } from '@/hooks/useKycCameraCheck' interface BridgeKycModalFlowProps { isOpen: boolean @@ -32,10 +34,24 @@ export const InitiateBridgeKYCModal = ({ } = useBridgeKycFlow({ onKycSuccess, flow, onManualClose }) const { addParamStep } = useClaimLink() + const { + showCameraWarning, + setShowCameraWarning, + mediaCheckResult, + handleVerifyClick: checkAndInitiate, + handleContinueAnyway, + handleOpenInBrowser, + isChecking, + } = useKycCameraCheck({ + onInitiateKyc: handleInitiateKyc, + onClose, + saveRedirect: saveRedirectUrl, + }) + const handleVerifyClick = async () => { addParamStep('bank') - const result = await handleInitiateKyc() - if (result?.success) { + const result = await checkAndInitiate() + if (result?.shouldProceed) { saveRedirectUrl() onClose() } @@ -44,7 +60,7 @@ export const InitiateBridgeKYCModal = ({ return ( <> - + + {mediaCheckResult && ( + setShowCameraWarning(false)} + onContinueAnyway={handleContinueAnyway} + onOpenInBrowser={handleOpenInBrowser} + mediaCheckResult={mediaCheckResult} + /> + )} + + openMantecaKyc(country), + onClose, + }) + useEffect(() => { const handleMessage = (event: MessageEvent) => { + // Validate origin for security + if (!event.origin.includes('manteca')) return + if (event.data.source === 'peanut-kyc-success') { onKycSuccess?.() } @@ -51,12 +69,12 @@ const InitiateMantecaKYCModal = ({ return () => { window.removeEventListener('message', handleMessage) } - }, []) + }, [onKycSuccess]) return ( <> openMantecaKyc(country), + text: isLoading || isChecking ? 'Loading...' : (ctaText ?? 'Verify now'), + onClick: handleVerifyClick, variant: 'purple', - disabled: isLoading, + disabled: isLoading || isChecking, shadowSize: '4', icon: 'check-circle', className: 'h-11', @@ -79,7 +97,22 @@ const InitiateMantecaKYCModal = ({ ]} footer={footer} /> - + + {mediaCheckResult && ( + setShowCameraWarning(false)} + onContinueAnyway={handleContinueAnyway} + onOpenInBrowser={handleOpenInBrowser} + mediaCheckResult={mediaCheckResult} + /> + )} + + ) } diff --git a/src/hooks/useKycCameraCheck.ts b/src/hooks/useKycCameraCheck.ts new file mode 100644 index 000000000..992b65c11 --- /dev/null +++ b/src/hooks/useKycCameraCheck.ts @@ -0,0 +1,80 @@ +import { useState } from 'react' +import { checkKycMediaReadiness, type MediaCheckResult } from '@/utils/mediaPermissions.utils' + +interface UseKycCameraCheckOptions { + onInitiateKyc: () => Promise<{ success: boolean; url?: string; data?: { kycLink?: string } } | undefined> + onClose: () => void + saveRedirect?: () => void +} + +/** + * Hook to handle camera/microphone pre-flight checks for KYC flows + * Manages warning modal state and provides handlers for user actions + */ +export function useKycCameraCheck({ onInitiateKyc, onClose, saveRedirect }: UseKycCameraCheckOptions) { + const [showCameraWarning, setShowCameraWarning] = useState(false) + const [mediaCheckResult, setMediaCheckResult] = useState(null) + const [kycUrlForBrowser, setKycUrlForBrowser] = useState(null) + const [isChecking, setIsChecking] = useState(false) + + const handleVerifyClick = async () => { + // Prevent double-clicks + if (isChecking) return { shouldProceed: false } + setIsChecking(true) + + try { + // Pre-flight check: see if camera/mic are available + const mediaCheck = await checkKycMediaReadiness() + + // Always call KYC initiation once + const result = await onInitiateKyc() + + // If media is not supported or it's a restricted environment, show warning + if (result?.success && (!mediaCheck.supported || mediaCheck.severity === 'warning')) { + setMediaCheckResult(mediaCheck) + const url = result.url || result.data?.kycLink + if (url) { + setKycUrlForBrowser(url) + setShowCameraWarning(true) + return { shouldProceed: false } + } + } + + return { shouldProceed: result?.success ?? false } + } finally { + setIsChecking(false) + } + } + + const handleContinueAnyway = () => { + setShowCameraWarning(false) + saveRedirect?.() + onClose() + } + + const handleOpenInBrowser = () => { + if (kycUrlForBrowser) { + // Validate URL is from expected domain for security + try { + const url = new URL(kycUrlForBrowser) + if (url.protocol === 'https:') { + window.open(kycUrlForBrowser, '_blank') + } + } catch { + console.error('Invalid KYC URL') + } + } + setShowCameraWarning(false) + onClose() + } + + return { + showCameraWarning, + setShowCameraWarning, + mediaCheckResult, + handleVerifyClick, + handleContinueAnyway, + handleOpenInBrowser, + isChecking, + } +} diff --git a/src/hooks/useMantecaKycFlow.ts b/src/hooks/useMantecaKycFlow.ts index ff57a2c82..cb9b2c158 100644 --- a/src/hooks/useMantecaKycFlow.ts +++ b/src/hooks/useMantecaKycFlow.ts @@ -89,7 +89,7 @@ export const useMantecaKycFlow = ({ onClose, onSuccess, onManualClose, country } src: url, visible: true, }) - return { success: true as const } + return { success: true as const, url } } catch (e: unknown) { const message = e instanceof Error ? e.message : 'Failed to initiate onboarding' setError(message) diff --git a/src/styles/globals.css b/src/styles/globals.css index 2b46af93a..0d4284057 100644 --- a/src/styles/globals.css +++ b/src/styles/globals.css @@ -14,17 +14,6 @@ /* disable tap highlight */ -webkit-tap-highlight-color: transparent; } - - /* Aggressive light mode enforcement for Android/Brave dark mode override */ - @media (prefers-color-scheme: dark) { - :root, - html, - body { - color-scheme: light !important; - background-color: white !important; - color: black !important; - } - } } @layer utilities { @@ -401,7 +390,7 @@ Firefox input[type='number'] { /* Panel styling */ .panel { - @apply rounded-md border-2 border-n-1 bg-white shadow-lg shadow-md ring-2 ring-white; + @apply rounded-md border-2 border-n-1 bg-white shadow-lg shadow-md ring-2 ring-white dark:border-white dark:bg-n-1 dark:ring-n-1; } .panel-sm { @@ -427,7 +416,7 @@ Firefox input[type='number'] { /* Form styling */ .input-text { - @apply h-12 w-full border border-n-1 bg-white px-3 font-medium outline-none ring-2 ring-white transition-colors focus:border-primary-1; + @apply h-12 w-full border border-n-1 bg-white px-3 font-medium outline-none ring-2 ring-white transition-colors focus:border-primary-1 dark:border-white dark:bg-n-1 dark:text-white dark:placeholder:text-white/75 dark:focus:border-primary-1; } .input-text-inset { @@ -436,11 +425,11 @@ Firefox input[type='number'] { /* Decoration */ .border-rounded { - @apply rounded-md border-2 border-n-1; + @apply rounded-md border-2 border-n-1 dark:border-white; } .ring-sm { - @apply shadow-md ring-2 ring-white; + @apply shadow-md ring-2 ring-white dark:ring-n-1; } .font-roboto-400-50 { diff --git a/src/utils/mediaPermissions.utils.ts b/src/utils/mediaPermissions.utils.ts new file mode 100644 index 000000000..2ea716452 --- /dev/null +++ b/src/utils/mediaPermissions.utils.ts @@ -0,0 +1,117 @@ +/** + * Utility functions for checking camera and microphone availability + * before initiating KYC flows that require media permissions + */ + +export interface MediaCheckResult { + supported: boolean + hasCamera: boolean + hasMicrophone: boolean + message?: string + severity: 'error' | 'warning' | 'success' +} + +/** + * Checks if camera and microphone are available on the device + * This does NOT request permissions, only checks availability + */ +export async function checkMediaAvailability(): Promise { + try { + // Check if getUserMedia API is available + if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) { + return { + supported: false, + hasCamera: false, + hasMicrophone: false, + message: 'Your browser does not support camera or microphone access.', + severity: 'error', + } + } + + // Try to enumerate devices (doesn't require permission) + const devices = await navigator.mediaDevices.enumerateDevices() + const hasCamera = devices.some((device) => device.kind === 'videoinput') + const hasMicrophone = devices.some((device) => device.kind === 'audioinput') + + // Both camera and microphone required for KYC + if (!hasCamera && !hasMicrophone) { + return { + supported: false, + hasCamera: false, + hasMicrophone: false, + message: 'No camera or microphone detected on your device.', + severity: 'error', + } + } + + if (!hasCamera) { + return { + supported: false, + hasCamera: false, + hasMicrophone: true, + message: 'No camera detected. KYC verification requires a camera.', + severity: 'error', + } + } + + if (!hasMicrophone) { + return { + supported: true, + hasCamera: true, + hasMicrophone: false, + message: 'No microphone detected. Some verification steps may require a microphone.', + severity: 'warning', + } + } + + return { + supported: true, + hasCamera: true, + hasMicrophone: true, + severity: 'success', + } + } catch (error) { + console.error('Error checking media availability:', error) + return { + supported: false, + hasCamera: false, + hasMicrophone: false, + message: 'Unable to check camera and microphone availability.', + severity: 'warning', + } + } +} + +/** + * Checks if running in an environment where iframe camera/mic access is restricted + * (e.g., iOS in-app browsers, some WebViews) + */ +export function isRestrictedEnvironment(): boolean { + if (typeof navigator === 'undefined') return false + const ua = navigator.userAgent + + // iOS WebView or Instagram/Facebook in-app browsers often restrict iframe permissions + const iosWebView = /(iPhone|iPod|iPad).*AppleWebKit(?!.*Safari)/i.test(ua) + const inAppBrowser = /FBAN|FBAV|Instagram|Twitter|Line|WhatsApp/i.test(ua) + + return iosWebView || inAppBrowser +} + +/** + * Combined check for KYC readiness + */ +export async function checkKycMediaReadiness(): Promise { + const mediaCheck = await checkMediaAvailability() + + // If restricted environment and camera available, warn about potential iframe issues + if (mediaCheck.hasCamera && isRestrictedEnvironment()) { + return { + ...mediaCheck, + message: + 'Camera access in embedded windows may be restricted in this app. For best results, open verification in your browser.', + severity: 'warning', + } + } + + return mediaCheck +} diff --git a/tailwind.config.js b/tailwind.config.js index 1b7cc116a..55292eab7 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -3,7 +3,7 @@ const plugin = require('tailwindcss/plugin') /** @type {import('tailwindcss').Config} */ module.exports = { - darkMode: false, // Disable dark mode completely + darkMode: ['class', '[data-theme="dark"]'], content: [ './src/pages/**/*.{js,ts,jsx,tsx,mdx}', './src/components/**/*.{js,ts,jsx,tsx,mdx}',