From 6cdc690445b68b45e930f5c09e3038525905cf01 Mon Sep 17 00:00:00 2001 From: rindicomfort Date: Mon, 1 Jun 2026 21:30:09 +0100 Subject: [PATCH 1/2] fix(#75): add biometric auth service, hook, and lock screen - src/services/auth/biometricService.ts: - Lazy-loads expo-local-authentication so app never crashes if the native module is not yet linked - isAvailable(): checks hardware + enrollment - getSupportedTypes(): fingerprint / facial / iris - authenticate(): wraps authenticateAsync with PIN fallback option - authenticateIfEnabled(): reads settings and skips prompt when biometrics are disabled - getSettings() / saveSettings(): persists { enabled, fallbackToPIN } to AsyncStorage - src/hooks/useBiometricAuth.ts: - Loads availability, supported types, and settings on mount - authenticate() triggers prompt and tracks loading/error/cancelled - saveSettings() persists user preference changes - src/components/BiometricGate.tsx: - Lock screen shown on launch when biometrics are enabled - Auto-prompts on mount and on foreground (AppState listener) - Shows biometric icon (Face ID / fingerprint / iris / lock) - Error card with retry button on failure - Passes through immediately when biometrics disabled or unavailable Requires: npx expo install expo-local-authentication --- src/components/BiometricGate.tsx | 171 +++++++++++++++++++++++++ src/hooks/useBiometricAuth.ts | 112 ++++++++++++++++ src/services/auth/biometricService.ts | 176 ++++++++++++++++++++++++++ 3 files changed, 459 insertions(+) create mode 100644 src/components/BiometricGate.tsx create mode 100644 src/hooks/useBiometricAuth.ts create mode 100644 src/services/auth/biometricService.ts diff --git a/src/components/BiometricGate.tsx b/src/components/BiometricGate.tsx new file mode 100644 index 00000000..de405775 --- /dev/null +++ b/src/components/BiometricGate.tsx @@ -0,0 +1,171 @@ +/** + * BiometricGate — wraps the app and prompts for biometric auth on launch. + * + * Place this inside App.tsx around . It renders a lock screen + * until the user authenticates. If biometrics are disabled in settings it + * renders children immediately. + * + * + * + * + */ + +import React, { useEffect, useState, ReactNode } from 'react'; +import { + View, + Text, + StyleSheet, + TouchableOpacity, + ActivityIndicator, + SafeAreaView, + AppState, + AppStateStatus, +} from 'react-native'; +import { useBiometricAuth } from '../hooks/useBiometricAuth'; +import { colors, spacing, typography, borderRadius } from '../utils/constants'; + +const BIOMETRIC_ICON: Record = { + fingerprint: '👆', + facial: '🪪', + iris: '👁️', + none: '🔒', +}; + +interface Props { + children: ReactNode; +} + +const BiometricGate: React.FC = ({ children }) => { + const { + isAvailable, + isLoading, + isAuthenticated, + error, + cancelled, + supportedTypes, + settings, + authenticate, + clearError, + } = useBiometricAuth(); + + const [appState, setAppState] = useState(AppState.currentState); + + // Re-lock when app comes back from background + useEffect(() => { + const sub = AppState.addEventListener('change', (next) => { + if (appState === 'background' && next === 'active' && settings.enabled) { + // Re-prompt on foreground + void authenticate(); + } + setAppState(next); + }); + return () => sub.remove(); + }, [appState, settings.enabled, authenticate]); + + // Auto-prompt on mount when biometrics are enabled + useEffect(() => { + if (!isLoading && settings.enabled && isAvailable && !isAuthenticated) { + void authenticate(); + } + }, [isLoading, settings.enabled, isAvailable]); // eslint-disable-line react-hooks/exhaustive-deps + + // Pass through immediately when biometrics are disabled or unavailable + if (!settings.enabled || !isAvailable) { + return <>{children}; + } + + // Show children once authenticated + if (isAuthenticated) { + return <>{children}; + } + + const icon = BIOMETRIC_ICON[supportedTypes[0] ?? 'none']; + + return ( + + + {icon} + SubTrackr is Locked + + Authenticate to access your subscriptions and wallet. + + + {isLoading ? ( + + ) : ( + <> + {error && !cancelled && ( + + {error} + + )} + + { clearError(); void authenticate(); }} + accessibilityRole="button" + accessibilityLabel="Unlock with biometrics"> + + {cancelled ? 'Try Again' : `Unlock with ${supportedTypes[0] === 'facial' ? 'Face ID' : 'Biometrics'}`} + + + + )} + + + ); +}; + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: colors.background, + alignItems: 'center', + justifyContent: 'center', + padding: spacing.lg, + }, + card: { + backgroundColor: colors.surface, + borderRadius: borderRadius.xl, + padding: spacing.xl, + alignItems: 'center', + width: '100%', + borderWidth: 1, + borderColor: colors.border, + }, + icon: { fontSize: 56, marginBottom: spacing.md }, + title: { ...typography.h2, color: colors.text, textAlign: 'center', marginBottom: spacing.sm }, + subtitle: { + ...typography.body, + color: colors.textSecondary, + textAlign: 'center', + lineHeight: 22, + marginBottom: spacing.lg, + }, + spinner: { marginVertical: spacing.lg }, + errorBox: { + backgroundColor: colors.error + '22', + borderRadius: borderRadius.md, + padding: spacing.md, + marginBottom: spacing.md, + width: '100%', + }, + errorText: { ...typography.body, color: colors.error, textAlign: 'center' }, + btn: { + backgroundColor: colors.primary, + borderRadius: borderRadius.md, + paddingVertical: spacing.md, + paddingHorizontal: spacing.xl, + minHeight: 48, + alignItems: 'center', + width: '100%', + }, + btnText: { ...typography.body, color: colors.text, fontWeight: '700' }, +}); + +export default BiometricGate; diff --git a/src/hooks/useBiometricAuth.ts b/src/hooks/useBiometricAuth.ts new file mode 100644 index 00000000..c85a87f8 --- /dev/null +++ b/src/hooks/useBiometricAuth.ts @@ -0,0 +1,112 @@ +/** + * useBiometricAuth — React hook for biometric authentication. + * + * Handles the full lifecycle: + * - Checks hardware availability on mount + * - Exposes authenticate() to trigger the system prompt + * - Tracks loading / success / error state + * - Reads and writes user settings (enabled, fallbackToPIN) + * + * Usage: + * const { isAvailable, isAuthenticated, authenticate, settings, saveSettings } = + * useBiometricAuth(); + */ + +import { useState, useEffect, useCallback } from 'react'; +import { biometricService, BiometricSettings, BiometricType } from '../services/auth/biometricService'; + +interface BiometricAuthState { + /** True when the device has enrolled biometrics. */ + isAvailable: boolean; + /** True while checking availability or authenticating. */ + isLoading: boolean; + /** True after a successful authentication in this session. */ + isAuthenticated: boolean; + /** Error message from the last failed attempt. */ + error: string | null; + /** Whether the user cancelled the last prompt. */ + cancelled: boolean; + /** Supported biometric types on this device. */ + supportedTypes: BiometricType[]; + /** Persisted user settings. */ + settings: BiometricSettings; + /** Trigger the biometric prompt. */ + authenticate: (reason?: string) => Promise; + /** Persist updated settings. */ + saveSettings: (patch: Partial) => Promise; + /** Clear the current error. */ + clearError: () => void; +} + +export function useBiometricAuth(): BiometricAuthState { + const [isAvailable, setIsAvailable] = useState(false); + const [isLoading, setIsLoading] = useState(true); + const [isAuthenticated, setIsAuthenticated] = useState(false); + const [error, setError] = useState(null); + const [cancelled, setCancelled] = useState(false); + const [supportedTypes, setSupportedTypes] = useState(['none']); + const [settings, setSettings] = useState({ enabled: false, fallbackToPIN: true }); + + // Load availability and settings on mount + useEffect(() => { + let cancelled = false; + const init = async () => { + const [available, types, savedSettings] = await Promise.all([ + biometricService.isAvailable(), + biometricService.getSupportedTypes(), + biometricService.getSettings(), + ]); + if (!cancelled) { + setIsAvailable(available); + setSupportedTypes(types); + setSettings(savedSettings); + setIsLoading(false); + } + }; + void init(); + return () => { cancelled = true; }; + }, []); + + const authenticate = useCallback(async (reason?: string): Promise => { + setIsLoading(true); + setError(null); + setCancelled(false); + + const result = await biometricService.authenticate( + reason ?? 'Authenticate to access SubTrackr', + settings.fallbackToPIN + ); + + setIsLoading(false); + + if (result.success) { + setIsAuthenticated(true); + return true; + } + + setIsAuthenticated(false); + setCancelled(result.cancelled ?? false); + setError(result.error ?? 'Authentication failed.'); + return false; + }, [settings.fallbackToPIN]); + + const saveSettings = useCallback(async (patch: Partial) => { + const updated = await biometricService.saveSettings(patch); + setSettings(updated); + }, []); + + const clearError = useCallback(() => setError(null), []); + + return { + isAvailable, + isLoading, + isAuthenticated, + error, + cancelled, + supportedTypes, + settings, + authenticate, + saveSettings, + clearError, + }; +} diff --git a/src/services/auth/biometricService.ts b/src/services/auth/biometricService.ts new file mode 100644 index 00000000..2847bcc2 --- /dev/null +++ b/src/services/auth/biometricService.ts @@ -0,0 +1,176 @@ +/** + * BiometricService — wraps expo-local-authentication for Face ID / Touch ID / + * fingerprint authentication with PIN fallback. + * + * Install the native module once: + * npx expo install expo-local-authentication + * + * The service degrades gracefully when the module is unavailable (e.g. in + * Jest or on a device with no enrolled biometrics) so the rest of the app + * never needs to guard against import errors. + */ + +import AsyncStorage from '@react-native-async-storage/async-storage'; + +const SETTINGS_KEY = '@subtrackr/biometric_settings'; + +// ─── Types ──────────────────────────────────────────────────────────────────── + +export type BiometricType = 'fingerprint' | 'facial' | 'iris' | 'none'; + +export interface BiometricSettings { + /** Whether the user has opted in to biometric lock. */ + enabled: boolean; + /** Whether to fall back to device PIN/passcode when biometrics fail. */ + fallbackToPIN: boolean; +} + +export interface BiometricAuthResult { + success: boolean; + /** Human-readable reason for failure, if any. */ + error?: string; + /** Whether the user cancelled the prompt. */ + cancelled?: boolean; +} + +// ─── Lazy-load expo-local-authentication ───────────────────────────────────── +// We import dynamically so the app doesn't crash if the native module hasn't +// been linked yet (e.g. in Expo Go or unit tests). + +type LocalAuth = typeof import('expo-local-authentication'); + +let _localAuth: LocalAuth | null = null; + +async function getLocalAuth(): Promise { + if (_localAuth) return _localAuth; + try { + _localAuth = await import('expo-local-authentication'); + return _localAuth; + } catch { + return null; + } +} + +// ─── Service ────────────────────────────────────────────────────────────────── + +class BiometricService { + // ── Settings persistence ─────────────────────────────────────────────────── + + async getSettings(): Promise { + try { + const raw = await AsyncStorage.getItem(SETTINGS_KEY); + if (raw) return JSON.parse(raw) as BiometricSettings; + } catch { + // Fall through to defaults + } + return { enabled: false, fallbackToPIN: true }; + } + + async saveSettings(settings: Partial): Promise { + const current = await this.getSettings(); + const merged = { ...current, ...settings }; + await AsyncStorage.setItem(SETTINGS_KEY, JSON.stringify(merged)); + return merged; + } + + // ── Hardware / enrollment checks ─────────────────────────────────────────── + + /** + * Returns true when the device has biometric hardware AND the user has + * enrolled at least one biometric credential. + */ + async isAvailable(): Promise { + const lib = await getLocalAuth(); + if (!lib) return false; + try { + const compatible = await lib.hasHardwareAsync(); + if (!compatible) return false; + const enrolled = await lib.isEnrolledAsync(); + return enrolled; + } catch { + return false; + } + } + + /** + * Returns the list of biometric types supported by the device + * (e.g. fingerprint, facial recognition). + */ + async getSupportedTypes(): Promise { + const lib = await getLocalAuth(); + if (!lib) return ['none']; + try { + const types = await lib.supportedAuthenticationTypesAsync(); + const AuthType = lib.AuthenticationType; + return types.map((t) => { + if (t === AuthType.FINGERPRINT) return 'fingerprint'; + if (t === AuthType.FACIAL_RECOGNITION) return 'facial'; + if (t === AuthType.IRIS) return 'iris'; + return 'none'; + }); + } catch { + return ['none']; + } + } + + // ── Authentication ───────────────────────────────────────────────────────── + + /** + * Prompt the user to authenticate with biometrics. + * + * @param reason Message shown in the system prompt (e.g. "Unlock SubTrackr"). + * @param fallbackToPIN When true, the system prompt includes a PIN fallback. + */ + async authenticate( + reason = 'Authenticate to access SubTrackr', + fallbackToPIN = true + ): Promise { + const lib = await getLocalAuth(); + if (!lib) { + return { success: false, error: 'Biometric authentication is not available on this device.' }; + } + + const available = await this.isAvailable(); + if (!available) { + return { success: false, error: 'No biometric credentials enrolled on this device.' }; + } + + try { + const result = await lib.authenticateAsync({ + promptMessage: reason, + fallbackLabel: fallbackToPIN ? 'Use PIN' : '', + disableDeviceFallback: !fallbackToPIN, + cancelLabel: 'Cancel', + }); + + if (result.success) return { success: true }; + + if (result.error === 'user_cancel' || result.error === 'system_cancel') { + return { success: false, cancelled: true, error: 'Authentication cancelled.' }; + } + + return { + success: false, + error: result.error ?? 'Authentication failed. Please try again.', + }; + } catch (e) { + return { + success: false, + error: e instanceof Error ? e.message : 'An unexpected error occurred.', + }; + } + } + + /** + * Convenience method: reads settings and authenticates only when biometrics + * are enabled by the user. Returns `{ success: true }` immediately when + * biometrics are disabled (so callers don't need to check settings first). + */ + async authenticateIfEnabled(reason?: string): Promise { + const settings = await this.getSettings(); + if (!settings.enabled) return { success: true }; + return this.authenticate(reason, settings.fallbackToPIN); + } +} + +export const biometricService = new BiometricService(); From f0c53f4d4039134ebaeda31578dfff0e66a78728 Mon Sep 17 00:00:00 2001 From: rindicomfort Date: Mon, 1 Jun 2026 21:30:24 +0100 Subject: [PATCH 2/2] fix(#75): wire BiometricGate into App.tsx - Wrap AppNavigator with BiometricGate inside ErrorBoundary - Import BiometricGate from src/components/BiometricGate - Gate renders lock screen on launch when biometrics are enabled; passes through immediately when disabled or unavailable --- App.tsx | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/App.tsx b/App.tsx index f08cd6bf..11d12c98 100644 --- a/App.tsx +++ b/App.tsx @@ -6,6 +6,7 @@ import { AppNavigator } from './src/navigation/AppNavigator'; import { useNotifications } from './src/hooks/useNotifications'; import { useTransactionQueue } from './src/hooks/useTransactionQueue'; import ErrorBoundary from './src/components/ErrorBoundary'; +import BiometricGate from './src/components/BiometricGate'; import { initI18n } from './src/i18n/config'; import i18n from './src/i18n/config'; import { I18nextProvider } from 'react-i18next'; @@ -114,10 +115,12 @@ export default function App() { - - - - + + + + + +