Skip to content
Merged
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
10 changes: 6 additions & 4 deletions App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -204,10 +204,12 @@ export default function App() {
<View style={{ flex: 1 }} testID="app-root">
<StatusBar style="light" />
<ErrorBoundary>
<I18nextProvider i18n={i18n}>
<NotificationBootstrap />
<AppNavigator />
</I18nextProvider>
<BiometricGate>
<I18nextProvider i18n={i18n}>
<NotificationBootstrap />
<AppNavigator />
</I18nextProvider>
</BiometricGate>
</ErrorBoundary>
<AppKit />
<CrashRecoveryModal
Expand Down
171 changes: 171 additions & 0 deletions src/components/BiometricGate.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
/**
* BiometricGate — wraps the app and prompts for biometric auth on launch.
*
* Place this inside App.tsx around <AppNavigator />. It renders a lock screen
* until the user authenticates. If biometrics are disabled in settings it
* renders children immediately.
*
* <BiometricGate>
* <AppNavigator />
* </BiometricGate>
*/

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<string, string> = {
fingerprint: '👆',
facial: '🪪',
iris: '👁️',
none: '🔒',
};

interface Props {
children: ReactNode;
}

const BiometricGate: React.FC<Props> = ({ children }) => {
const {
isAvailable,
isLoading,
isAuthenticated,
error,
cancelled,
supportedTypes,
settings,
authenticate,
clearError,
} = useBiometricAuth();

const [appState, setAppState] = useState<AppStateStatus>(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 (
<SafeAreaView style={styles.container} testID="biometric-gate">
<View style={styles.card}>
<Text style={styles.icon}>{icon}</Text>
<Text style={styles.title}>SubTrackr is Locked</Text>
<Text style={styles.subtitle}>
Authenticate to access your subscriptions and wallet.
</Text>

{isLoading ? (
<ActivityIndicator
size="large"
color={colors.primary}
style={styles.spinner}
accessibilityLabel="Authenticating"
/>
) : (
<>
{error && !cancelled && (
<View style={styles.errorBox}>
<Text style={styles.errorText}>{error}</Text>
</View>
)}

<TouchableOpacity
style={styles.btn}
onPress={() => { clearError(); void authenticate(); }}
accessibilityRole="button"
accessibilityLabel="Unlock with biometrics">
<Text style={styles.btnText}>
{cancelled ? 'Try Again' : `Unlock with ${supportedTypes[0] === 'facial' ? 'Face ID' : 'Biometrics'}`}
</Text>
</TouchableOpacity>
</>
)}
</View>
</SafeAreaView>
);
};

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;
112 changes: 112 additions & 0 deletions src/hooks/useBiometricAuth.ts
Original file line number Diff line number Diff line change
@@ -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<boolean>;
/** Persist updated settings. */
saveSettings: (patch: Partial<BiometricSettings>) => Promise<void>;
/** 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<string | null>(null);
const [cancelled, setCancelled] = useState(false);
const [supportedTypes, setSupportedTypes] = useState<BiometricType[]>(['none']);
const [settings, setSettings] = useState<BiometricSettings>({ 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<boolean> => {
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<BiometricSettings>) => {
const updated = await biometricService.saveSettings(patch);
setSettings(updated);
}, []);

const clearError = useCallback(() => setError(null), []);

return {
isAvailable,
isLoading,
isAuthenticated,
error,
cancelled,
supportedTypes,
settings,
authenticate,
saveSettings,
clearError,
};
}
Loading
Loading