-
Notifications
You must be signed in to change notification settings - Fork 2
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat(mobile): add biometric local authentication (#235)
- Loading branch information
Showing
14 changed files
with
282 additions
and
86 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,43 @@ | ||
import { t } from '@lingui/macro' | ||
import { useLingui } from '@lingui/react' | ||
import * as LocalAuthentication from 'expo-local-authentication' | ||
import { LockKeyholeIcon, ScanFaceIcon } from 'lucide-react-native' | ||
import { useCallback, useEffect } from 'react' | ||
import { SafeAreaView } from 'react-native' | ||
import { Button } from '../ui/button' | ||
import { Text } from '../ui/text' | ||
|
||
type AuthLocalProps = { | ||
onAuthenticated?: () => void | ||
} | ||
|
||
export function AuthLocal({ onAuthenticated }: AuthLocalProps) { | ||
const { i18n } = useLingui() | ||
|
||
const handleAuthenticate = useCallback(async () => { | ||
const result = await LocalAuthentication.authenticateAsync({ | ||
// disableDeviceFallback: true, | ||
}) | ||
if (result.success) { | ||
onAuthenticated?.() | ||
} | ||
}, [onAuthenticated]) | ||
|
||
// biome-ignore lint/correctness/useExhaustiveDependencies: <explanation> | ||
useEffect(() => { | ||
handleAuthenticate() | ||
}, []) | ||
|
||
return ( | ||
<SafeAreaView className="flex-1 items-center justify-center gap-4 bg-muted"> | ||
<LockKeyholeIcon className="size-12 text-primary" /> | ||
<Text className="mx-8">{t( | ||
i18n, | ||
)`App is locked. Please authenticate to continue.`}</Text> | ||
<Button onPress={handleAuthenticate}> | ||
<ScanFaceIcon className="size-6 text-primary-foreground" /> | ||
<Text>{t(i18n)`Unlock`}</Text> | ||
</Button> | ||
</SafeAreaView> | ||
) | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,51 @@ | ||
import { useUserSettingsStore } from '@/stores/user-settings/store' | ||
import { t } from '@lingui/macro' | ||
import { useLingui } from '@lingui/react' | ||
import * as LocalAuthentication from 'expo-local-authentication' | ||
import { ScanFaceIcon } from 'lucide-react-native' | ||
import { useEffect, useState } from 'react' | ||
import { MenuItem } from '../common/menu-item' | ||
import { toast } from '../common/toast' | ||
import { Switch } from '../ui/switch' | ||
|
||
export function SetLocalAuth() { | ||
const { i18n } = useLingui() | ||
const [isBiometricSupported, setIsBiometricSupported] = useState(false) | ||
const { enabledLocalAuth, setEnabledLocalAuth } = useUserSettingsStore() | ||
|
||
useEffect(() => { | ||
;(async () => { | ||
const compatible = await LocalAuthentication.hasHardwareAsync() | ||
const enrolled = await LocalAuthentication.isEnrolledAsync() | ||
setIsBiometricSupported(compatible && enrolled) | ||
})() | ||
}, []) | ||
|
||
async function handleToggleLocalAuth(enabled: boolean) { | ||
const result = await LocalAuthentication.authenticateAsync({ | ||
// disableDeviceFallback: true, | ||
}) | ||
if (result.success) { | ||
setEnabledLocalAuth(enabled) | ||
} else { | ||
toast.error(result.warning ?? t(i18n)`Unknown error`) | ||
} | ||
} | ||
|
||
if (!isBiometricSupported) { | ||
return null | ||
} | ||
|
||
return ( | ||
<MenuItem | ||
label={t(i18n)`Login using FaceID`} | ||
icon={ScanFaceIcon} | ||
rightSection={ | ||
<Switch | ||
checked={enabledLocalAuth} | ||
onCheckedChange={handleToggleLocalAuth} | ||
/> | ||
} | ||
/> | ||
) | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,48 @@ | ||
import { useUserSettingsStore } from '@/stores/user-settings/store' | ||
import AsyncStorage from '@react-native-async-storage/async-storage' | ||
import { useCallback, useEffect, useState } from 'react' | ||
import { AppState, type AppStateStatus } from 'react-native' | ||
|
||
// 30 seconds | ||
const BIO_AUTH_EXPIRATION_TIME = 1000 * 30 | ||
|
||
export function useLocalAuth() { | ||
const [shouldAuthLocal, setShouldAuthLocal] = useState(false) | ||
const { enabledLocalAuth } = useUserSettingsStore() | ||
|
||
const changeAppStateListener = useCallback( | ||
async (status: AppStateStatus) => { | ||
if (!enabledLocalAuth) { | ||
AsyncStorage.removeItem('movedToBackgroundAt') | ||
return | ||
} | ||
|
||
if (status === 'background') { | ||
const date = Date.now() | ||
await AsyncStorage.setItem('movedToBackgroundAt', date.toString()) | ||
} | ||
|
||
if (status === 'active') { | ||
const date = await AsyncStorage.getItem('movedToBackgroundAt') | ||
if (date && Date.now() - Number(date) >= BIO_AUTH_EXPIRATION_TIME) { | ||
await AsyncStorage.removeItem('movedToBackgroundAt') | ||
setShouldAuthLocal(true) | ||
} | ||
} | ||
}, | ||
[enabledLocalAuth], | ||
) | ||
|
||
useEffect(() => { | ||
const subscription = AppState.addEventListener( | ||
'change', | ||
changeAppStateListener, | ||
) | ||
return subscription.remove | ||
}, [changeAppStateListener]) | ||
|
||
return { | ||
shouldAuthLocal, | ||
setShouldAuthLocal, | ||
} | ||
} |
Oops, something went wrong.