fix: biometrics input on mobile webview challenge modal (#1572)
This commit is contained in:
@@ -288,6 +288,53 @@ export class MobileDeviceInterface implements DeviceInterface {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
authenticateWithBiometrics() {
|
||||||
|
return new Promise<boolean>((resolve) => {
|
||||||
|
if (Platform.OS === 'android') {
|
||||||
|
FingerprintScanner.authenticate({
|
||||||
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
|
// @ts-ignore ts type does not exist for deviceCredentialAllowed
|
||||||
|
deviceCredentialAllowed: true,
|
||||||
|
description: 'Biometrics are required to access your notes.',
|
||||||
|
})
|
||||||
|
.then(() => {
|
||||||
|
FingerprintScanner.release()
|
||||||
|
resolve(true)
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
FingerprintScanner.release()
|
||||||
|
if (error.name === 'DeviceLocked') {
|
||||||
|
Alert.alert('Unsuccessful', 'Authentication failed. Wait 30 seconds to try again.')
|
||||||
|
} else {
|
||||||
|
Alert.alert('Unsuccessful', 'Authentication failed. Tap to try again.')
|
||||||
|
}
|
||||||
|
resolve(false)
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
// iOS
|
||||||
|
FingerprintScanner.authenticate({
|
||||||
|
fallbackEnabled: true,
|
||||||
|
description: 'This is required to access your notes.',
|
||||||
|
})
|
||||||
|
.then(() => {
|
||||||
|
FingerprintScanner.release()
|
||||||
|
resolve(true)
|
||||||
|
})
|
||||||
|
.catch((error_1) => {
|
||||||
|
FingerprintScanner.release()
|
||||||
|
if (error_1.name !== 'SystemCancel') {
|
||||||
|
if (error_1.name !== 'UserCancel') {
|
||||||
|
Alert.alert('Unsuccessful')
|
||||||
|
} else {
|
||||||
|
Alert.alert('Unsuccessful', 'Authentication failed. Tap to try again.')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
resolve(false)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
getRawKeychainValue(): Promise<RawKeychainValue | null | undefined> {
|
getRawKeychainValue(): Promise<RawKeychainValue | null | undefined> {
|
||||||
return Keychain.getKeys()
|
return Keychain.getKeys()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,4 +9,5 @@ export interface MobileDeviceInterface extends DeviceInterface {
|
|||||||
setAndroidScreenshotPrivacy(enable: boolean): Promise<void>
|
setAndroidScreenshotPrivacy(enable: boolean): Promise<void>
|
||||||
getMobileScreenshotPrivacyEnabled(): Promise<boolean | undefined>
|
getMobileScreenshotPrivacyEnabled(): Promise<boolean | undefined>
|
||||||
setMobileScreenshotPrivacyEnabled(isEnabled: boolean): Promise<void>
|
setMobileScreenshotPrivacyEnabled(isEnabled: boolean): Promise<void>
|
||||||
|
authenticateWithBiometrics(): Promise<boolean>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
Challenge,
|
Challenge,
|
||||||
ChallengePrompt,
|
ChallengePrompt,
|
||||||
ChallengeReason,
|
ChallengeReason,
|
||||||
|
ChallengeValidation,
|
||||||
ChallengeValue,
|
ChallengeValue,
|
||||||
removeFromArray,
|
removeFromArray,
|
||||||
} from '@standardnotes/snjs'
|
} from '@standardnotes/snjs'
|
||||||
@@ -17,6 +18,7 @@ import LockscreenWorkspaceSwitcher from './LockscreenWorkspaceSwitcher'
|
|||||||
import { ApplicationGroup } from '@/Application/ApplicationGroup'
|
import { ApplicationGroup } from '@/Application/ApplicationGroup'
|
||||||
import { ViewControllerManager } from '@/Controllers/ViewControllerManager'
|
import { ViewControllerManager } from '@/Controllers/ViewControllerManager'
|
||||||
import { ChallengeModalValues } from './ChallengeModalValues'
|
import { ChallengeModalValues } from './ChallengeModalValues'
|
||||||
|
import { InputValue } from './InputValue'
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
application: WebApplication
|
application: WebApplication
|
||||||
@@ -64,9 +66,11 @@ const ChallengeModal: FunctionComponent<Props> = ({
|
|||||||
const [isProcessing, setIsProcessing] = useState(false)
|
const [isProcessing, setIsProcessing] = useState(false)
|
||||||
const [, setProcessingPrompts] = useState<ChallengePrompt[]>([])
|
const [, setProcessingPrompts] = useState<ChallengePrompt[]>([])
|
||||||
const [bypassModalFocusLock, setBypassModalFocusLock] = useState(false)
|
const [bypassModalFocusLock, setBypassModalFocusLock] = useState(false)
|
||||||
|
|
||||||
const shouldShowForgotPasscode = [ChallengeReason.ApplicationUnlock, ChallengeReason.Migration].includes(
|
const shouldShowForgotPasscode = [ChallengeReason.ApplicationUnlock, ChallengeReason.Migration].includes(
|
||||||
challenge.reason,
|
challenge.reason,
|
||||||
)
|
)
|
||||||
|
|
||||||
const shouldShowWorkspaceSwitcher = challenge.reason === ChallengeReason.ApplicationUnlock
|
const shouldShowWorkspaceSwitcher = challenge.reason === ChallengeReason.ApplicationUnlock
|
||||||
|
|
||||||
const submit = useCallback(() => {
|
const submit = useCallback(() => {
|
||||||
@@ -106,7 +110,7 @@ const ChallengeModal: FunctionComponent<Props> = ({
|
|||||||
}, [application, challenge, isProcessing, isSubmitting, values])
|
}, [application, challenge, isProcessing, isSubmitting, values])
|
||||||
|
|
||||||
const onValueChange = useCallback(
|
const onValueChange = useCallback(
|
||||||
(value: string | number, prompt: ChallengePrompt) => {
|
(value: InputValue['value'], prompt: ChallengePrompt) => {
|
||||||
const newValues = { ...values }
|
const newValues = { ...values }
|
||||||
newValues[prompt.id].invalid = false
|
newValues[prompt.id].invalid = false
|
||||||
newValues[prompt.id].value = value
|
newValues[prompt.id].value = value
|
||||||
@@ -169,6 +173,17 @@ const ChallengeModal: FunctionComponent<Props> = ({
|
|||||||
}
|
}
|
||||||
}, [application, challenge, onDismiss])
|
}, [application, challenge, onDismiss])
|
||||||
|
|
||||||
|
const biometricPrompt = challenge.prompts.find((prompt) => prompt.validation === ChallengeValidation.Biometric)
|
||||||
|
const hasOnlyBiometricPrompt = challenge.prompts.length === 1 && !!biometricPrompt
|
||||||
|
const hasBiometricPromptValue = biometricPrompt && values[biometricPrompt.id].value
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const shouldAutoSubmit = hasOnlyBiometricPrompt && hasBiometricPromptValue
|
||||||
|
if (shouldAutoSubmit) {
|
||||||
|
submit()
|
||||||
|
}
|
||||||
|
}, [hasBiometricPromptValue, hasOnlyBiometricPrompt, submit])
|
||||||
|
|
||||||
if (!challenge.prompts) {
|
if (!challenge.prompts) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
@@ -201,11 +216,9 @@ const ChallengeModal: FunctionComponent<Props> = ({
|
|||||||
)}
|
)}
|
||||||
<ProtectedIllustration className="mb-4 h-30 w-30" />
|
<ProtectedIllustration className="mb-4 h-30 w-30" />
|
||||||
<div className="mb-3 max-w-76 text-center text-lg font-bold">{challenge.heading}</div>
|
<div className="mb-3 max-w-76 text-center text-lg font-bold">{challenge.heading}</div>
|
||||||
|
|
||||||
{challenge.subheading && (
|
{challenge.subheading && (
|
||||||
<div className="break-word mb-4 max-w-76 text-center text-sm">{challenge.subheading}</div>
|
<div className="break-word mb-4 max-w-76 text-center text-sm">{challenge.subheading}</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<form
|
<form
|
||||||
className="flex min-w-76 flex-col items-center"
|
className="flex min-w-76 flex-col items-center"
|
||||||
onSubmit={(e) => {
|
onSubmit={(e) => {
|
||||||
@@ -215,6 +228,7 @@ const ChallengeModal: FunctionComponent<Props> = ({
|
|||||||
>
|
>
|
||||||
{challenge.prompts.map((prompt, index) => (
|
{challenge.prompts.map((prompt, index) => (
|
||||||
<ChallengeModalPrompt
|
<ChallengeModalPrompt
|
||||||
|
application={application}
|
||||||
key={prompt.id}
|
key={prompt.id}
|
||||||
prompt={prompt}
|
prompt={prompt}
|
||||||
values={values}
|
values={values}
|
||||||
|
|||||||
@@ -1,25 +1,50 @@
|
|||||||
import { ChallengePrompt, ChallengeValidation, ProtectionSessionDurations } from '@standardnotes/snjs'
|
import {
|
||||||
|
ChallengePrompt,
|
||||||
|
ChallengeValidation,
|
||||||
|
MobileDeviceInterface,
|
||||||
|
ProtectionSessionDurations,
|
||||||
|
} from '@standardnotes/snjs'
|
||||||
import { FunctionComponent, useEffect, useRef } from 'react'
|
import { FunctionComponent, useEffect, useRef } from 'react'
|
||||||
import DecoratedInput from '@/Components/Input/DecoratedInput'
|
import DecoratedInput from '@/Components/Input/DecoratedInput'
|
||||||
import DecoratedPasswordInput from '@/Components/Input/DecoratedPasswordInput'
|
import DecoratedPasswordInput from '@/Components/Input/DecoratedPasswordInput'
|
||||||
import { ChallengeModalValues } from './ChallengeModalValues'
|
import { ChallengeModalValues } from './ChallengeModalValues'
|
||||||
|
import Button from '../Button/Button'
|
||||||
|
import { WebApplication } from '@/Application/Application'
|
||||||
|
import { InputValue } from './InputValue'
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
|
application: WebApplication
|
||||||
prompt: ChallengePrompt
|
prompt: ChallengePrompt
|
||||||
values: ChallengeModalValues
|
values: ChallengeModalValues
|
||||||
index: number
|
index: number
|
||||||
onValueChange: (value: string | number, prompt: ChallengePrompt) => void
|
onValueChange: (value: InputValue['value'], prompt: ChallengePrompt) => void
|
||||||
isInvalid: boolean
|
isInvalid: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const ChallengeModalPrompt: FunctionComponent<Props> = ({ prompt, values, index, onValueChange, isInvalid }) => {
|
const ChallengeModalPrompt: FunctionComponent<Props> = ({
|
||||||
|
application,
|
||||||
|
prompt,
|
||||||
|
values,
|
||||||
|
index,
|
||||||
|
onValueChange,
|
||||||
|
isInvalid,
|
||||||
|
}) => {
|
||||||
const inputRef = useRef<HTMLInputElement>(null)
|
const inputRef = useRef<HTMLInputElement>(null)
|
||||||
|
const biometricsButtonRef = useRef<HTMLButtonElement>(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (index === 0) {
|
const isNotFirstPrompt = index !== 0
|
||||||
|
|
||||||
|
if (isNotFirstPrompt) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (prompt.validation === ChallengeValidation.Biometric) {
|
||||||
|
biometricsButtonRef.current?.click()
|
||||||
|
} else {
|
||||||
inputRef.current?.focus()
|
inputRef.current?.focus()
|
||||||
}
|
}
|
||||||
}, [index])
|
}, [index, prompt.validation])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isInvalid) {
|
if (isInvalid) {
|
||||||
@@ -61,6 +86,22 @@ const ChallengeModalPrompt: FunctionComponent<Props> = ({ prompt, values, index,
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
) : prompt.validation === ChallengeValidation.Biometric ? (
|
||||||
|
<div className="min-w-76">
|
||||||
|
<Button
|
||||||
|
primary
|
||||||
|
fullWidth
|
||||||
|
onClick={async () => {
|
||||||
|
const authenticated = await (
|
||||||
|
application.deviceInterface as MobileDeviceInterface
|
||||||
|
).authenticateWithBiometrics()
|
||||||
|
onValueChange(authenticated, prompt)
|
||||||
|
}}
|
||||||
|
ref={biometricsButtonRef}
|
||||||
|
>
|
||||||
|
Tap to use biometrics
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
) : prompt.secureTextEntry ? (
|
) : prompt.secureTextEntry ? (
|
||||||
<DecoratedPasswordInput
|
<DecoratedPasswordInput
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
|
|||||||
Reference in New Issue
Block a user