Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 70 additions & 0 deletions src/hooks/useBiometrics.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import {act, renderHook, waitFor} from '@testing-library/react-native'
import {
authenticateAsync,
getEnrolledLevelAsync,
SecurityLevel,
} from 'expo-local-authentication'
import {useBiometrics} from '@/hooks/useBiometrics'

jest.mock('expo-local-authentication', () => ({
authenticateAsync: jest.fn(),
getEnrolledLevelAsync: jest.fn(),
SecurityLevel: {
NONE: 0,
BIOMETRIC: 1,
},
}))

const getEnrolledLevelAsyncMock = jest.mocked(getEnrolledLevelAsync)
const authenticateAsyncMock = jest.mocked(authenticateAsync)

describe('useBiometrics', () => {
beforeEach(() => {
jest.clearAllMocks()
})

it('fails authentication when no biometrics are enrolled', async () => {
getEnrolledLevelAsyncMock.mockResolvedValue(SecurityLevel.NONE)

const {result} = renderHook(() =>
useBiometrics({
autoTrigger: false,
promptMessage: 'Prompt',
}),
)

await act(async () => {
await result.current.authenticate()
})

await waitFor(() => {
expect(result.current.failed).toBe(true)
})

expect(result.current.authenticated).toBe(false)
expect(authenticateAsyncMock).not.toHaveBeenCalled()
})

it('authenticates when enrolled biometrics succeed', async () => {
getEnrolledLevelAsyncMock.mockResolvedValue(SecurityLevel.BIOMETRIC_STRONG)
authenticateAsyncMock.mockResolvedValue({success: true} as never)

const {result} = renderHook(() =>
useBiometrics({
autoTrigger: false,
promptMessage: 'Prompt',
}),
)

await act(async () => {
await result.current.authenticate()
})

await waitFor(() => {
expect(result.current.authenticated).toBe(true)
})

expect(result.current.failed).toBe(false)
expect(authenticateAsyncMock).toHaveBeenCalledTimes(1)
})
})
5 changes: 3 additions & 2 deletions src/hooks/useBiometrics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@ export const useBiometrics = ({
const [authenticated, setAuthenticated] = useState(false)
const [failed, setFailed] = useState(false)
const authenticate = useCallback(async () => {
setAuthenticated(false)

const enrolledLevel = await getEnrolledLevelAsync()

if (enrolledLevel !== SecurityLevel.NONE) {
Expand All @@ -61,8 +63,7 @@ export const useBiometrics = ({
},
)
} else {
setFailed(false)
setAuthenticated(true)
setFailed(true)
}
}, [
cancelButtonText,
Expand Down
56 changes: 54 additions & 2 deletions src/modules/access-code/hooks/useAccessCodeBiometrics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
supportedAuthenticationTypesAsync,
} from 'expo-local-authentication'
import {useState, useEffect, useCallback, useMemo} from 'react'
import {Platform} from 'react-native'
import {SvgIconName} from '@/components/ui/media/svgIcons'
import {usePermission} from '@/hooks/permissions/usePermission'
import {useDispatch} from '@/hooks/redux/useDispatch'
Expand Down Expand Up @@ -35,16 +36,64 @@ export const useAccessCodeBiometrics = () => {
[biometricsAuthenticationType],
)

useEffect(() => {
void isEnrolledAsync().then(setIsEnrolled)
const refreshEnrollment = useCallback(async (): Promise<boolean> => {
const enrolled = await isEnrolledAsync()

setIsEnrolled(enrolled)

return enrolled
}, [])

useEffect(() => {
void refreshEnrollment()
}, [refreshEnrollment])

const setUseBiometrics = useCallback(
(choice: boolean) =>
dispatch(accessCodeSlice.actions.setUseBiometrics(choice)),
[dispatch],
)

const updateUseBiometrics = useCallback(
async (shouldUseBiometrics: boolean): Promise<void> => {
if (!shouldUseBiometrics || !biometricsLabel) {
setUseBiometrics(false)

return
}

const enrolled = await refreshEnrollment()

if (!enrolled) {
setUseBiometrics(false)

return
}

if (
Platform.OS === 'ios' &&
biometricsAuthenticationType?.includes(
AuthenticationType.FACIAL_RECOGNITION,
)
) {
const granted = await requestPermission()

setUseBiometrics(granted)

return
}

setUseBiometrics(true)
},
[
biometricsAuthenticationType,
biometricsLabel,
refreshEnrollment,
requestPermission,
setUseBiometrics,
],
)

useEffect(() => {
const fetchBiometricsAuthenticationType = async () => {
setIsLoading(true)
Expand All @@ -68,11 +117,14 @@ export const useAccessCodeBiometrics = () => {
return {
biometricsAuthenticationType,
biometricsLabel,
isBiometricsSupported: !!biometricsLabel,
iconName,
isEnrolled,
isLoading,
refreshEnrollment,
requestPermission,
setUseBiometrics,
updateUseBiometrics,
useBiometrics,
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ const SwitchWrapper = ({children}: PropsWithChildren) => (
)

export const BiometricsPermissionScreen = () => {
const {biometricsLabel, isLoading, setUseBiometrics} =
const {biometricsLabel, isLoading, updateUseBiometrics} =
useAccessCodeBiometrics()

const {isLandscape} = useDeviceContext()
Expand All @@ -31,9 +31,9 @@ export const BiometricsPermissionScreen = () => {

const onSubmit = useCallback(
({enableBiometrics}: BiometricsPermissionForm) => {
setUseBiometrics(enableBiometrics)
void updateUseBiometrics(enableBiometrics)
},
[setUseBiometrics],
[updateUseBiometrics],
)

if (isLoading) {
Expand Down
96 changes: 96 additions & 0 deletions src/modules/user/components/UserMenu.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import {render} from '@testing-library/react-native'
import {UserMenu} from '@/modules/user/components/UserMenu'
import {StoreProvider} from '@/providers/store.provider'

let mockAccessCode = '1234'
let mockIsBiometricsSupported = true
let mockIsEnrolled = true
let mockBiometricsLabel = 'Face ID'

const mockNavigate = jest.fn()

jest.mock('@/hooks/navigation/useNavigation', () => ({
useNavigation: () => ({
navigate: mockNavigate,
}),
}))

jest.mock('@/modules/access-code/hooks/useGetSecureAccessCode', () => ({
useGetSecureAccessCode: () => ({
accessCode: mockAccessCode,
isLoading: false,
}),
}))

jest.mock('@/modules/access-code/hooks/useAccessCodeBiometrics', () => ({
useAccessCodeBiometrics: () => ({
biometricsLabel: mockBiometricsLabel,
isBiometricsSupported: mockIsBiometricsSupported,
isEnrolled: mockIsEnrolled,
}),
}))

jest.mock('@/modules/user/components/AppInfoCopyButtons', () => ({
AppInfoCopyButtons: () => null,
}))

describe('UserMenu - Security user menu items', () => {
beforeEach(() => {
mockAccessCode = '1234'
mockIsBiometricsSupported = true
mockIsEnrolled = true
mockBiometricsLabel = 'Face ID'
mockNavigate.mockClear()
})

it('shows the biometrics navigation item when biometrics are supported but not enrolled', () => {
mockIsEnrolled = false

const {getByText} = render(
<StoreProvider>
<UserMenu />
</StoreProvider>,
)

expect(getByText('Toegang met Face ID')).toBeTruthy()
expect(getByText('Wijzig toegangscode')).toBeTruthy()
})

it('hides the biometrics navigation item when biometrics are not supported', () => {
mockIsBiometricsSupported = false

const {getByText, queryByText} = render(
<StoreProvider>
<UserMenu />
</StoreProvider>,
)

expect(getByText('Wijzig toegangscode')).toBeTruthy()
expect(queryByText('Toegang met Face ID')).toBeNull()
})

it('does not show security menu items when access code is not set', () => {
mockAccessCode = ''

const {queryByText} = render(
<StoreProvider>
<UserMenu />
</StoreProvider>,
)

expect(queryByText('Wijzig toegangscode')).toBeNull()
expect(queryByText('Toegang met Face ID')).toBeNull()
})

it('shows the biometrics label in the menu item as returned by the useAccessCodeBiometrics hook', () => {
mockBiometricsLabel = 'Test'

const {getByText} = render(
<StoreProvider>
<UserMenu />
</StoreProvider>,
)

expect(getByText('Toegang met Test')).toBeTruthy()
})
})
4 changes: 2 additions & 2 deletions src/modules/user/components/UserMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ const generalSection = [
] satisfies UserMenuSection[]

const MenuSection = ({title, navigationItems}: UserMenuSection) => {
const {biometricsLabel, isEnrolled} = useAccessCodeBiometrics()
const {biometricsLabel, isBiometricsSupported} = useAccessCodeBiometrics()
const {navigate} = useNavigation()

return (
Expand All @@ -76,7 +76,7 @@ const MenuSection = ({title, navigationItems}: UserMenuSection) => {
<Column gutter="xxs">
{navigationItems.map(({icon, ...item}) =>
item.route === UserRouteName.userBiometrics &&
(!biometricsLabel || !isEnrolled) ? null : (
(!biometricsLabel || !isBiometricsSupported) ? null : (
<NavigationButton
Comment thread
WouterAms marked this conversation as resolved.
chevronSize="md"
emphasis="default"
Expand Down
36 changes: 11 additions & 25 deletions src/modules/user/screens/UserBiometrics.screen.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,4 @@
import {AuthenticationType} from 'expo-local-authentication'
import {ReactNode, useCallback} from 'react'
import {Platform} from 'react-native'
import {Screen} from '@/components/features/screen/Screen'
import {Box} from '@/components/ui/containers/Box'
import {Switch} from '@/components/ui/forms/Switch'
Expand All @@ -22,38 +20,26 @@ const SwitchWrapper = ({children}: Props) => (
)

export const UserBiometricsScreen = () => {
const {
biometricsLabel,
biometricsAuthenticationType,
requestPermission,
setUseBiometrics,
useBiometrics,
} = useAccessCodeBiometrics()
const {biometricsLabel, isEnrolled, updateUseBiometrics, useBiometrics} =
useAccessCodeBiometrics()
const navigateToInstructionsScreen = useNavigateToInstructionsScreen(
Permissions.biometrics,
)

const onChange = useCallback(() => {
setUseBiometrics(!useBiometrics)
if (!useBiometrics && !isEnrolled) {
if (!isEnrolled) {
navigateToInstructionsScreen()
}

if (
Platform.OS === 'ios' &&
biometricsAuthenticationType?.includes(
AuthenticationType.FACIAL_RECOGNITION,
) &&
!useBiometrics
) {
void requestPermission().then(granted => {
if (!granted) {
navigateToInstructionsScreen()
}
})
return
}

void updateUseBiometrics(!useBiometrics)
}, [
biometricsAuthenticationType,
isEnrolled,
navigateToInstructionsScreen,
requestPermission,
setUseBiometrics,
updateUseBiometrics,
useBiometrics,
])

Expand Down
Loading