diff --git a/packages/mobile/src/constants/theme.ts b/packages/mobile/src/constants/theme.ts index c661414..9748830 100644 --- a/packages/mobile/src/constants/theme.ts +++ b/packages/mobile/src/constants/theme.ts @@ -52,6 +52,8 @@ export const SHADOWS = { export const API_URL = process.env.EXPO_PUBLIC_API_URL || 'https://api.shieldai.freno.me/api/v1'; +export const EAS_PROJECT_ID = process.env.EXPO_PUBLIC_EAS_PROJECT_ID || ''; + export const getSeverityColor = (severity: string): string => { switch (severity) { case 'critical': return COLORS.danger; diff --git a/packages/mobile/src/hooks/useBiometricAuth.ts b/packages/mobile/src/hooks/useBiometricAuth.ts index 501f23e..b1ba01e 100644 --- a/packages/mobile/src/hooks/useBiometricAuth.ts +++ b/packages/mobile/src/hooks/useBiometricAuth.ts @@ -1,24 +1,42 @@ -import { useCallback } from 'react'; +import { useCallback, useState, useEffect } from 'react'; import * as LocalAuthentication from 'expo-local-authentication'; +import { Alert } from 'react-native'; import { useSettingsStore } from '@/store/settingsStore'; export function useBiometricAuth() { const { isBiometricEnabled } = useSettingsStore(); + const [biometricsAvailable, setBiometricsAvailable] = useState(false); + + useEffect(() => { + (async () => { + const hasHardware = await LocalAuthentication.hasHardwareAsync(); + const isEnrolled = await LocalAuthentication.isEnrolledAsync(); + setBiometricsAvailable(hasHardware && isEnrolled); + })(); + }, []); const authenticate = useCallback(async (): Promise => { if (!isBiometricEnabled) return true; try { const isAvailable = await LocalAuthentication.hasHardwareAsync(); - if (!isAvailable) return true; - const isEnrolled = await LocalAuthentication.isEnrolledAsync(); - if (!isEnrolled) return true; + + if (!isAvailable || !isEnrolled) { + Alert.alert( + 'Biometric Unavailable', + 'Biometric authentication requires hardware and enrollment. Please use your password.', + [{ text: 'OK' }] + ); + useSettingsStore.getState().toggleBiometric(false); + return false; + } const result = await LocalAuthentication.authenticateAsync({ promptMessage: 'Authenticate with biometrics', fallbackLabel: 'Use passcode', cancelLabel: 'Cancel', + disableDeviceFallback: false, }); return result.success; @@ -31,7 +49,14 @@ export function useBiometricAuth() { const isAvailable = await LocalAuthentication.hasHardwareAsync(); const isEnrolled = await LocalAuthentication.isEnrolledAsync(); - if (!isAvailable || !isEnrolled) return false; + if (!isAvailable || !isEnrolled) { + Alert.alert( + 'Biometric Not Available', + 'Your device does not support biometric authentication or no credentials are enrolled.', + [{ text: 'OK' }] + ); + return false; + } const result = await LocalAuthentication.authenticateAsync({ promptMessage: 'Enable biometric authentication', @@ -49,5 +74,5 @@ export function useBiometricAuth() { useSettingsStore.getState().toggleBiometric(false); }, []); - return { authenticate, enableBiometric, disableBiometric, isBiometricEnabled }; + return { authenticate, enableBiometric, disableBiometric, isBiometricEnabled, biometricsAvailable }; } diff --git a/packages/mobile/src/hooks/usePushNotifications.ts b/packages/mobile/src/hooks/usePushNotifications.ts index 07bd8c9..96a4692 100644 --- a/packages/mobile/src/hooks/usePushNotifications.ts +++ b/packages/mobile/src/hooks/usePushNotifications.ts @@ -4,6 +4,7 @@ import { Platform } from 'react-native'; import * as Device from 'expo-device'; import { deviceService, notificationService } from '@shieldai/mobile-api-client'; import { useSettingsStore } from '@/store/settingsStore'; +import { EAS_PROJECT_ID } from '@/constants/theme'; export function usePushNotifications() { const preferencesRef = useRef(useSettingsStore.getState().preferences); @@ -40,8 +41,13 @@ export function usePushNotifications() { return null; } + if (!EAS_PROJECT_ID) { + console.warn('EAS_PROJECT_ID not configured — push notifications disabled'); + return null; + } + const token = (await Notifications.getExpoPushTokenAsync({ - projectId: 'shieldai-project-id', + projectId: EAS_PROJECT_ID, })).data; try { diff --git a/packages/mobile/src/screens/auth/LoginScreen.tsx b/packages/mobile/src/screens/auth/LoginScreen.tsx index d71b861..bfdde75 100644 --- a/packages/mobile/src/screens/auth/LoginScreen.tsx +++ b/packages/mobile/src/screens/auth/LoginScreen.tsx @@ -1,5 +1,6 @@ import React, { useState } from 'react'; import { StyleSheet, Text, View, SafeAreaView, KeyboardAvoidingView, Platform, ScrollView, Alert } from 'react-native'; +import AsyncStorage from '@react-native-async-storage/async-storage'; import { useNavigation, NavigationProp } from '@react-navigation/native'; import { useAuthStore } from '@/store/authStore'; import { Button, Input } from '@/components'; @@ -10,6 +11,10 @@ type RootStackParamList = { Register: undefined; }; +const LOGIN_ATTEMPT_KEY = '@shieldai_login_attempts'; +const MAX_ATTEMPTS = 5; +const LOCKOUT_MS = 5 * 60 * 1000; + export function LoginScreen() { const navigation = useNavigation>(); const { login, isLoading, clearError } = useAuthStore(); @@ -27,11 +32,43 @@ export function LoginScreen() { return; } + const now = Date.now(); + const stored = await AsyncStorage.getItem(LOGIN_ATTEMPT_KEY); + let attempts = { count: 0, lockedUntil: 0 }; + if (stored) { + try { + attempts = JSON.parse(stored); + } catch { /* ignore corrupt data */ } + } + + if (attempts.lockedUntil > now) { + const remaining = Math.ceil((attempts.lockedUntil - now) / 1000 / 60); + setFormError(`Too many failed attempts. Try again in ${remaining} minute${remaining !== 1 ? 's' : ''}.`); + return; + } + + if (attempts.count >= MAX_ATTEMPTS && attempts.lockedUntil <= now) { + attempts.lockedUntil = now + LOCKOUT_MS; + attempts.count = 0; + await AsyncStorage.setItem(LOGIN_ATTEMPT_KEY, JSON.stringify(attempts)); + const remaining = Math.ceil(LOCKOUT_MS / 1000 / 60); + setFormError(`Too many failed attempts. Locked for ${remaining} minutes.`); + return; + } + try { await login(email, password); + await AsyncStorage.removeItem(LOGIN_ATTEMPT_KEY); } catch (err: any) { - setFormError(err.message || 'Login failed. Please try again.'); - Alert.alert('Login Failed', err.message || 'Please check your credentials and try again.'); + attempts.count = (attempts.count || 0) + 1; + if (attempts.count >= MAX_ATTEMPTS) { + attempts.lockedUntil = Date.now() + LOCKOUT_MS; + } + await AsyncStorage.setItem(LOGIN_ATTEMPT_KEY, JSON.stringify(attempts)); + const remaining = MAX_ATTEMPTS - attempts.count; + const warning = remaining > 0 ? ` ${remaining} attempt${remaining !== 1 ? 's' : ''} remaining.` : ''; + setFormError(err.message || 'Login failed. Please try again.' + warning); + Alert.alert('Login Failed', err.message || 'Please check your credentials and try again.' + warning); } }; diff --git a/packages/mobile/src/screens/darkwatch/DarkWatchScreen.tsx b/packages/mobile/src/screens/darkwatch/DarkWatchScreen.tsx index af646df..e55573b 100644 --- a/packages/mobile/src/screens/darkwatch/DarkWatchScreen.tsx +++ b/packages/mobile/src/screens/darkwatch/DarkWatchScreen.tsx @@ -11,9 +11,28 @@ export function DarkWatchScreen() { const [newItemName, setNewItemName] = useState(''); const [newItemValue, setNewItemValue] = useState(''); const [newItemType, setNewItemType] = useState('person'); + const [validationError, setValidationError] = useState(''); + + const validateWatchItem = (name: string, value: string, type: string): string | null => { + if (!name.trim() || !value.trim()) return 'Name and value are required'; + if (name.length > 100) return 'Name must be 100 characters or less'; + if (value.length > 200) return 'Value must be 200 characters or less'; + if (type === 'email' && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) { + return 'Enter a valid email address'; + } + if (type === 'phone' && !/^\+?[\d\s-()]{7,20}$/.test(value)) { + return 'Enter a valid phone number'; + } + return null; + }; const handleAddItem = async () => { - if (!newItemName || !newItemValue) return; + const error = validateWatchItem(newItemName, newItemValue, newItemType); + if (error) { + setValidationError(error); + return; + } + setValidationError(''); await addWatchItem({ name: newItemName, @@ -125,6 +144,12 @@ export function DarkWatchScreen() { Add Watch Item + {validationError && ( + + {validationError} + + )} + -