Skip to content

Commit

Permalink
feat(mobile): add biometric local authentication (#235)
Browse files Browse the repository at this point in the history
  • Loading branch information
bkdev98 committed Aug 20, 2024
1 parent 8217000 commit 9384071
Show file tree
Hide file tree
Showing 14 changed files with 282 additions and 86 deletions.
6 changes: 6 additions & 0 deletions apps/mobile/app.json
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,12 @@
"project": "6pm-mobile",
"organization": "get6pm"
}
],
[
"expo-local-authentication",
{
"faceIDPermission": "Allow $(PRODUCT_NAME) to use Face ID to secure your account"
}
]
],
"experiments": {
Expand Down
15 changes: 6 additions & 9 deletions apps/mobile/app/(app)/(tabs)/settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import { MenuItem } from '@/components/common/menu-item'
import { toast } from '@/components/common/toast'
import { ProfileCard } from '@/components/setting/profile-card'
import { SelectDefaultCurrency } from '@/components/setting/select-default-currency'
import { SetLocalAuth } from '@/components/setting/set-local-auth'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Switch } from '@/components/ui/switch'
import { Text } from '@/components/ui/text'
Expand All @@ -29,7 +31,6 @@ import {
LockKeyholeIcon,
LogOutIcon,
MessageSquareQuoteIcon,
ScanFaceIcon,
ScrollTextIcon,
ShapesIcon,
Share2Icon,
Expand Down Expand Up @@ -94,7 +95,9 @@ export default function SettingsScreen() {
label={t(i18n)`Magic inbox`}
icon={InboxIcon}
rightSection={
<ChevronRightIcon className="h-5 w-5 text-primary" />
<Badge variant="outline">
<Text className="text-xs">{t(i18n)`Coming soon`}</Text>
</Badge>
}
/>
</Link>
Expand Down Expand Up @@ -129,13 +132,7 @@ export default function SettingsScreen() {
/>
</Link>
<SelectDefaultCurrency />
<MenuItem
label={t(i18n)`Login using FaceID`}
icon={ScanFaceIcon}
rightSection={
<Switch checked={false} onCheckedChange={console.log} />
}
/>
<SetLocalAuth />
<MenuItem
label={t(i18n)`Push notifications`}
icon={BellIcon}
Expand Down
7 changes: 7 additions & 0 deletions apps/mobile/app/(app)/_layout.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { AuthLocal } from '@/components/auth/auth-local'
import { BackButton } from '@/components/common/back-button'
import { Button } from '@/components/ui/button'
import { useLocalAuth } from '@/hooks/use-local-auth'
import { useScheduleNotificationTrigger } from '@/hooks/use-schedule-notification'
import { useColorScheme } from '@/hooks/useColorScheme'
import { theme } from '@/lib/theme'
Expand All @@ -14,6 +16,7 @@ export default function AuthenticatedLayout() {
const { user, isLoaded, isSignedIn } = useUser()
const { colorScheme } = useColorScheme()
const { i18n } = useLingui()
const { shouldAuthLocal, setShouldAuthLocal } = useLocalAuth()
useScheduleNotificationTrigger()

useEffect(() => {
Expand All @@ -30,6 +33,10 @@ export default function AuthenticatedLayout() {
return <Redirect href={'/onboarding/step-one'} />
}

if (shouldAuthLocal) {
return <AuthLocal onAuthenticated={() => setShouldAuthLocal(false)} />
}

return (
<Stack
screenOptions={{
Expand Down
43 changes: 43 additions & 0 deletions apps/mobile/components/auth/auth-local.tsx
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>
)
}
6 changes: 3 additions & 3 deletions apps/mobile/components/setting/profile-card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -149,13 +149,13 @@ export function ProfileCard() {
intensity={15}
className="flex flex-row items-center justify-between gap-2 px-4 py-3"
>
<View className="flex flex-row items-center gap-3">
<View className="flex flex-1 flex-row items-center gap-3">
<UserAvatar user={user!} fallbackClassName="bg-card" />
<View>
<View className="flex-1 flex-grow">
<Badge variant="default" className="mb-1 self-start rounded-md">
<Text className="font-medium text-xs">Saver</Text>
</Badge>
<Text className="font-medium text-primary">
<Text className="line-clamp-1 flex-grow font-medium text-primary">
{user?.fullName ?? user?.primaryEmailAddress?.emailAddress}
</Text>
</View>
Expand Down
51 changes: 51 additions & 0 deletions apps/mobile/components/setting/set-local-auth.tsx
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}
/>
}
/>
)
}
48 changes: 48 additions & 0 deletions apps/mobile/hooks/use-local-auth.tsx
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,
}
}
Loading

0 comments on commit 9384071

Please sign in to comment.