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
116 changes: 116 additions & 0 deletions src/__tests__/hooks/useRequireReauth.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import { act, renderHook } from '@testing-library/react-native';

import { useRequireReauth } from '../../hooks/useRequireReauth';
import { useDeviceStore } from '../../store/deviceStore';
import { useBiometricAuth } from '../../hooks/useBiometricAuth';

Check warning on line 5 in src/__tests__/hooks/useRequireReauth.test.ts

View workflow job for this annotation

GitHub Actions / ci

`../../hooks/useBiometricAuth` import should occur before import of `../../hooks/useRequireReauth`

jest.mock('../../hooks/useBiometricAuth', () => ({
useBiometricAuth: jest.fn(),
}));

const mockUseBiometricAuth = useBiometricAuth as jest.Mock;

describe('useRequireReauth', () => {
const mockAuthenticate = jest.fn();

beforeEach(() => {
jest.clearAllMocks();
useDeviceStore.setState({
lastBiometricAuth: null,
});
mockUseBiometricAuth.mockReturnValue({
authenticate: mockAuthenticate,
isAvailable: true,
isEnabled: true,
});
});

it('triggers biometric challenge if lastBiometricAuth is null', async () => {
mockAuthenticate.mockResolvedValue({ user: {}, tokens: {} });

const { result } = renderHook(() => useRequireReauth());
let allowed: boolean | null = null;

await act(async () => {
allowed = await result.current.performReauthCheck();
});

expect(mockAuthenticate).toHaveBeenCalledTimes(1);
expect(allowed).toBe(true);
expect(useDeviceStore.getState().lastBiometricAuth).not.toBeNull();
});

it('skips biometric challenge if within 5-minute threshold', async () => {
const recentTime = Date.now() - 120000; // 2 minutes ago
useDeviceStore.setState({
lastBiometricAuth: recentTime,
});

const { result } = renderHook(() => useRequireReauth(300000));
let allowed: boolean | null = null;

await act(async () => {
allowed = await result.current.performReauthCheck();
});

expect(mockAuthenticate).not.toHaveBeenCalled();
expect(allowed).toBe(true);
expect(useDeviceStore.getState().lastBiometricAuth).toBe(recentTime);
});

it('triggers biometric challenge if past 5-minute threshold', async () => {
const oldTime = Date.now() - 360000; // 6 minutes ago
useDeviceStore.setState({
lastBiometricAuth: oldTime,
});
mockAuthenticate.mockResolvedValue({ user: {}, tokens: {} });

const { result } = renderHook(() => useRequireReauth(300000));
let allowed: boolean | null = null;

await act(async () => {
allowed = await result.current.performReauthCheck();
});

expect(mockAuthenticate).toHaveBeenCalledTimes(1);
expect(allowed).toBe(true);
expect(useDeviceStore.getState().lastBiometricAuth).toBeGreaterThan(oldTime);
});

it('blocks operation (returns false) if biometric challenge is cancelled/fails', async () => {
const oldTime = Date.now() - 360000; // 6 minutes ago
useDeviceStore.setState({
lastBiometricAuth: oldTime,
});
mockAuthenticate.mockResolvedValue(null); // biometric cancelled

const { result } = renderHook(() => useRequireReauth(300000));
let allowed: boolean | null = null;

await act(async () => {
allowed = await result.current.performReauthCheck();
});

expect(mockAuthenticate).toHaveBeenCalledTimes(1);
expect(allowed).toBe(false);
expect(useDeviceStore.getState().lastBiometricAuth).toBe(oldTime); // timestamp not updated
});

it('enforces minimum 1-minute threshold', async () => {
// 50 seconds ago, past the requested 30s threshold but within the enforced 60s minimum
const time = Date.now() - 50000;
useDeviceStore.setState({
lastBiometricAuth: time,
});

const { result } = renderHook(() => useRequireReauth(30000)); // request 30s threshold
let allowed: boolean | null = null;

await act(async () => {
allowed = await result.current.performReauthCheck();
});

expect(mockAuthenticate).not.toHaveBeenCalled();
expect(allowed).toBe(true);
});
});
17 changes: 16 additions & 1 deletion src/components/mobile/MobileProfile.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
import {
ActivityIndicator,
Alert,
Animated,
Platform,
SafeAreaView,
Expand All @@ -35,11 +36,11 @@
import { AvatarCamera } from './AvatarCamera';
import { MobileFormInput } from './MobileFormInput';
import { StatisticsDisplay } from './StatisticsDisplay';
import { useFormCache } from '../../hooks/useFormCache';
import { useFormCache, useRequireReauth } from '../../hooks';
import { PROFILE_FORM_CACHE_KEYS, cacheFormValues } from '../../services/formCache';

Check failure on line 40 in src/components/mobile/MobileProfile.tsx

View workflow job for this annotation

GitHub Actions / ci

Parse errors in imported module '../../services/formCache': '}' expected. (170:0)
import { configureNext } from '../../utils/layoutAnimation';
import { AppText as Text } from '../common/AppText';
import { CachedImage } from '../ui/CachedImage';

Check failure on line 43 in src/components/mobile/MobileProfile.tsx

View workflow job for this annotation

GitHub Actions / ci

Parse errors in imported module '../ui/CachedImage': Expected corresponding JSX closing tag for 'View'. (267:8)
import { ShimmerItem as Skeleton } from '../common/SkeletonLoader';

// Enable LayoutAnimation on Android
Expand Down Expand Up @@ -262,6 +263,7 @@
const [isEditing, setIsEditing] = useState(false);
const [isCameraVisible, setIsCameraVisible] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const { performReauthCheck } = useRequireReauth();

const fadeAnim = useRef(new Animated.Value(isLoading ? 0 : 1)).current;

Expand Down Expand Up @@ -364,6 +366,19 @@
}, []);

const handleSave = handleSubmit(async (data) => {
const isEmailChanged = data.email.trim() !== profile.email;
if (isEmailChanged) {
const authorized = await performReauthCheck();
if (!authorized) {
Alert.alert(
'Re-authentication Failed',
'Biometric verification is required to change your account email.',
[{ text: 'OK' }]
);
return;
}
}

setIsSaving(true);
await new Promise(resolve => setTimeout(resolve, 800));
setProfile(prev => ({
Expand Down
67 changes: 66 additions & 1 deletion src/components/mobile/MobileSettings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import {
BarChart2,
ChevronDown,
ChevronUp,
CreditCard,
Database,
Download,
Eye,
Expand All @@ -10,6 +11,7 @@ import {
LogOut,
RefreshCw,
Settings2,
ShieldAlert,
Sun,
Trash2,
User,
Expand All @@ -18,11 +20,12 @@ import {
} from 'lucide-react-native';
import React, { memo, useCallback, useState } from 'react';
import { ActivityIndicator, Alert, ScrollView, TouchableOpacity, View } from 'react-native';
import { useRouter } from 'expo-router';

import { NativeToggle } from './NativeToggle';
import { PickerOption, SettingsPicker } from './SettingsPicker';
import { SettingsSection } from './SettingsSection';
import { useDynamicFontSize } from '../../hooks';
import { useDynamicFontSize, useRequireReauth } from '../../hooks';
import { useBiometricAuth } from '../../hooks/useBiometricAuth';
import { useFormCache } from '../../hooks/useFormCache';
import { useAppStore, useTheme } from '../../store';
Expand Down Expand Up @@ -145,6 +148,8 @@ const AdvancedToggle = ({ expanded, onToggle }: AdvancedToggleProps) => {
export const MobileSettings = ({ onSignOut, onChangePassword, onLinkedAccounts }: any) => {
const theme = useTheme();
const setTheme = useAppStore(state => state.setTheme);
const router = useRouter();
const { performReauthCheck } = useRequireReauth();
// Progressive disclosure: advanced settings collapsed by default
const [showAdvancedSettings, setShowAdvancedSettings] = useState(false);

Expand Down Expand Up @@ -242,6 +247,42 @@ export const MobileSettings = ({ onSignOut, onChangePassword, onLinkedAccounts }
setShowAdvancedSettings(prev => !prev);
}, []);

const handleChangePaymentMethod = useCallback(async () => {
const authorized = await performReauthCheck();
if (authorized) {
Alert.alert('Payment Method', 'Payment method updated successfully.');
} else {
Alert.alert('Re-authentication Failed', 'Verification required to change payment method.');
}
}, [performReauthCheck]);

const handleViewFullCardNumber = useCallback(async () => {
const authorized = await performReauthCheck();
if (authorized) {
Alert.alert('Card Details', 'Card Number: **** **** **** 4242');
} else {
Alert.alert('Re-authentication Failed', 'Verification required to view card details.');
}
}, [performReauthCheck]);

const handleExportData = useCallback(async () => {
const authorized = await performReauthCheck();
if (authorized) {
Alert.alert('Export Data', 'Your personal data export request has been submitted successfully.');
} else {
Alert.alert('Re-authentication Failed', 'Verification required to export personal data.');
}
}, [performReauthCheck]);

const handleAdminDashboard = useCallback(async () => {
const authorized = await performReauthCheck();
if (authorized) {
router.push('/health-dashboard');
} else {
Alert.alert('Re-authentication Failed', 'Verification required to access Admin Dashboard.');
}
}, [performReauthCheck, router]);

return (
<ScrollView className="flex-1 bg-gray-50 dark:bg-gray-900">
{/* ── ESSENTIAL: ACCOUNT ─────────────────────────────── */}
Expand Down Expand Up @@ -287,6 +328,16 @@ export const MobileSettings = ({ onSignOut, onChangePassword, onLinkedAccounts }
)}

<SettingRow icon={<User size={18} />} label="Change Password" onPress={onChangePassword} />
<SettingRow
icon={<CreditCard size={18} color="#f59e0b" />}
label="Change Payment Method"
onPress={handleChangePaymentMethod}
/>
<SettingRow
icon={<CreditCard size={18} color="#10b981" />}
label="View Full Card Number"
onPress={handleViewFullCardNumber}
/>
</SettingsSection>

{/* ── ESSENTIAL: APP ─────────────────────────────────── */}
Expand Down Expand Up @@ -332,6 +383,13 @@ export const MobileSettings = ({ onSignOut, onChangePassword, onLinkedAccounts }
onPress={handleClearFormCache}
destructive
/>

<SettingRow
icon={<Download size={18} color="#6366f1" />}
label="Export Personal Data"
description="Export your account details and learning progress"
onPress={handleExportData}
/>
</SettingsSection>

{/* DOWNLOADS */}
Expand Down Expand Up @@ -384,6 +442,13 @@ export const MobileSettings = ({ onSignOut, onChangePassword, onLinkedAccounts }
label="Clipboard Optimizer"
description="Test & profile asynchronous clipboard operations"
/>

<SettingRow
icon={<ShieldAlert size={18} color="#ef4444" />}
label="Admin Dashboard"
description="Access systems health & performance diagnostics"
onPress={handleAdminDashboard}
/>
</SettingsSection>
</>
)}
Expand Down
1 change: 1 addition & 0 deletions src/hooks/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ export * from './useAdaptiveTheme';
export * from './useAnalytics';
export { AuthProvider, useAuth } from './useAuth';
export * from './useBiometricAuth';
export * from './useRequireReauth';
export * from './useCamera';
export * from './useCoursePagination';
export * from './useCourseProgress';
Expand Down
47 changes: 47 additions & 0 deletions src/hooks/useRequireReauth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { useCallback } from 'react';

import { useBiometricAuth } from './useBiometricAuth';
import { useDeviceStore } from '../store/deviceStore';

/**
* Hook to enforce biometric re-authentication for sensitive operations.
*
* Checks if the last biometric authentication challenge occurred more than the threshold.
* If true, triggers a biometric challenge. If successful, updates the last auth timestamp
* and returns true. If authentication fails, cancels, or is unavailable, returns false.
*
* @param threshold The time in milliseconds after which re-authentication is required.
* Defaults to 5 minutes (300,000ms), minimum 1 minute (60,000ms).
*/
export function useRequireReauth(threshold = 300000) {
const actualThreshold = Math.max(threshold, 60000); // Enforce minimum of 1 minute

const lastBiometricAuth = useDeviceStore(state => state.lastBiometricAuth);
const setLastBiometricAuth = useDeviceStore(state => state.setLastBiometricAuth);
const { authenticate } = useBiometricAuth();

const performReauthCheck = useCallback(async (): Promise<boolean> => {
const now = Date.now();
const needsReauth =
lastBiometricAuth === null || now - lastBiometricAuth > actualThreshold;

if (needsReauth) {
try {
const result = await authenticate();
if (result) {
setLastBiometricAuth(Date.now());
return true;
}
return false;
} catch (error) {
return false;
}
}

return true;
}, [lastBiometricAuth, setLastBiometricAuth, authenticate, actualThreshold]);

return {
performReauthCheck,
};
}
6 changes: 6 additions & 0 deletions src/store/deviceStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,13 @@ interface DeviceState {
isInBackground: boolean;
/** True when the device is jailbroken (iOS) or rooted (Android) */
isDeviceCompromised: boolean;
lastBiometricAuth: number | null;

// Actions
updateBatteryInfo: (level: number, state: Battery.BatteryState, lowPowerMode: boolean) => void;
setIsInBackground: (isBg: boolean) => void;
setDeviceCompromised: (compromised: boolean) => void;
setLastBiometricAuth: (timestamp: number | null) => void;
/** Runs jailbreak/root detection and updates state */
runDeviceCompromisedCheck: () => Promise<boolean>;
}
Expand All @@ -28,6 +30,7 @@ export const useDeviceStore = create<DeviceState>(set => ({
lowPowerMode: false,
isInBackground: false,
isDeviceCompromised: false,
lastBiometricAuth: null,

updateBatteryInfo: (level, state, lowPowerMode) => {
const isLowBattery = level > 0 && level < 0.2;
Expand All @@ -44,6 +47,9 @@ export const useDeviceStore = create<DeviceState>(set => ({
setDeviceCompromised: (compromised: boolean) => {
set({ isDeviceCompromised: compromised });
},
setLastBiometricAuth: (timestamp) => {
set({ lastBiometricAuth: timestamp });
},
runDeviceCompromisedCheck: async () => {
const compromised = await checkDeviceCompromised();
set({ isDeviceCompromised: compromised });
Expand Down
Loading