import { WebApplication } from '@/Application/Application' import { DialogContent, DialogOverlay } from '@reach/dialog' import { ButtonType, Challenge, ChallengePrompt, ChallengeReason, ChallengeValidation, ChallengeValue, removeFromArray, } from '@standardnotes/snjs' import { ProtectedIllustration } from '@standardnotes/icons' import { FunctionComponent, useCallback, useEffect, useRef, useState } from 'react' import Button from '@/Components/Button/Button' import Icon from '@/Components/Icon/Icon' import ChallengeModalPrompt from './ChallengePrompt' import LockscreenWorkspaceSwitcher from './LockscreenWorkspaceSwitcher' import { ApplicationGroup } from '@/Application/ApplicationGroup' import { ViewControllerManager } from '@/Controllers/ViewControllerManager' import { ChallengeModalValues } from './ChallengeModalValues' import { InputValue } from './InputValue' import { isMobileScreen } from '@/Utils' import { classNames } from '@standardnotes/utils' type Props = { application: WebApplication viewControllerManager: ViewControllerManager mainApplicationGroup: ApplicationGroup challenge: Challenge onDismiss?: (challenge: Challenge) => void } const validateValues = (values: ChallengeModalValues, prompts: ChallengePrompt[]): ChallengeModalValues | undefined => { let hasInvalidValues = false const validatedValues = { ...values } for (const prompt of prompts) { const value = validatedValues[prompt.id] if (typeof value.value === 'string' && value.value.length === 0) { validatedValues[prompt.id].invalid = true hasInvalidValues = true } } if (!hasInvalidValues) { return validatedValues } return undefined } const ChallengeModal: FunctionComponent = ({ application, viewControllerManager, mainApplicationGroup, challenge, onDismiss, }) => { const promptsContainerRef = useRef(null) const [values, setValues] = useState(() => { const values = {} as ChallengeModalValues for (const prompt of challenge.prompts) { values[prompt.id] = { prompt, value: prompt.initialValue ?? '', invalid: false, } } return values }) const [isSubmitting, setIsSubmitting] = useState(false) const [isProcessing, setIsProcessing] = useState(false) const [, setProcessingPrompts] = useState([]) const [bypassModalFocusLock, setBypassModalFocusLock] = useState(false) const shouldShowForgotPasscode = [ChallengeReason.ApplicationUnlock, ChallengeReason.Migration].includes( challenge.reason, ) const shouldShowWorkspaceSwitcher = challenge.reason === ChallengeReason.ApplicationUnlock const submit = useCallback(() => { const validatedValues = validateValues(values, challenge.prompts) if (!validatedValues) { return } if (isSubmitting || isProcessing) { return } setIsSubmitting(true) setIsProcessing(true) const valuesToProcess: ChallengeValue[] = [] for (const inputValue of Object.values(validatedValues)) { const rawValue = inputValue.value const value = { prompt: inputValue.prompt, value: rawValue } valuesToProcess.push(value) } const processingPrompts = valuesToProcess.map((v) => v.prompt) setIsProcessing(processingPrompts.length > 0) setProcessingPrompts(processingPrompts) /** * Unfortunately neccessary to wait 50ms so that the above setState call completely * updates the UI to change processing state, before we enter into UI blocking operation * (crypto key generation) */ setTimeout(() => { if (valuesToProcess.length > 0) { application.submitValuesForChallenge(challenge, valuesToProcess).catch(console.error) } else { setIsProcessing(false) } setIsSubmitting(false) }, 50) }, [application, challenge, isProcessing, isSubmitting, values]) const onValueChange = useCallback( (value: InputValue['value'], prompt: ChallengePrompt) => { const newValues = { ...values } newValues[prompt.id].invalid = false newValues[prompt.id].value = value setValues(newValues) }, [values], ) const cancelChallenge = useCallback(() => { if (challenge.cancelable) { application.cancelChallenge(challenge) onDismiss?.(challenge) } }, [application, challenge, onDismiss]) useEffect(() => { const removeChallengeObserver = application.addChallengeObserver(challenge, { onValidValue: (value) => { setValues((values) => { const newValues = { ...values } newValues[value.prompt.id].invalid = false return newValues }) setProcessingPrompts((currentlyProcessingPrompts) => { const processingPrompts = currentlyProcessingPrompts.slice() removeFromArray(processingPrompts, value.prompt) setIsProcessing(processingPrompts.length > 0) return processingPrompts }) }, onInvalidValue: (value) => { setValues((values) => { const newValues = { ...values } newValues[value.prompt.id].invalid = true return newValues }) /** If custom validation, treat all values together and not individually */ if (!value.prompt.validates) { setProcessingPrompts([]) setIsProcessing(false) } else { setProcessingPrompts((currentlyProcessingPrompts) => { const processingPrompts = currentlyProcessingPrompts.slice() removeFromArray(processingPrompts, value.prompt) setIsProcessing(processingPrompts.length > 0) return processingPrompts }) } }, onComplete: () => { onDismiss?.(challenge) }, onCancel: () => { onDismiss?.(challenge) }, }) return () => { removeChallengeObserver() } }, [application, challenge, onDismiss]) const biometricPrompt = challenge.prompts.find((prompt) => prompt.validation === ChallengeValidation.Biometric) const hasOnlyBiometricPrompt = challenge.prompts.length === 1 && !!biometricPrompt const wasBiometricInputSuccessful = biometricPrompt && !!values[biometricPrompt.id].value const hasSecureTextPrompt = challenge.prompts.some((prompt) => prompt.secureTextEntry) useEffect(() => { const shouldAutoSubmit = hasOnlyBiometricPrompt && wasBiometricInputSuccessful const shouldFocusSecureTextPrompt = hasSecureTextPrompt && wasBiometricInputSuccessful if (shouldAutoSubmit) { submit() } else if (shouldFocusSecureTextPrompt) { const secureTextEntry = promptsContainerRef.current?.querySelector( 'input[type="password"]', ) as HTMLInputElement | null secureTextEntry?.focus() } }, [wasBiometricInputSuccessful, hasOnlyBiometricPrompt, submit, hasSecureTextPrompt]) useEffect(() => { const removeListener = application.addAndroidBackHandlerEventListener(() => { if (challenge.cancelable) { cancelChallenge() } return true }) return () => { if (removeListener) { removeListener() } } }, [application, cancelChallenge, challenge.cancelable]) if (!challenge.prompts) { return null } const isFullScreenBlocker = challenge.reason === ChallengeReason.ApplicationUnlock const isMobileOverlay = isMobileScreen() && !isFullScreenBlocker const contentClasses = classNames( 'challenge-modal relative flex flex-col items-center rounded border-solid border-border p-8 md:border', !isMobileScreen() && 'shadow-overlay-light', isMobileOverlay && 'border border-solid border-border shadow-overlay-light', isFullScreenBlocker && isMobileScreen() ? 'bg-passive-5' : 'bg-default', ) return ( {challenge.cancelable && ( )}
{challenge.heading}
{challenge.subheading && (
{challenge.subheading}
)}
{ e.preventDefault() submit() }} ref={promptsContainerRef} > {challenge.prompts.map((prompt, index) => ( ))} {shouldShowForgotPasscode && ( )} {shouldShowWorkspaceSwitcher && ( )}
) } export default ChallengeModal