security: fix 10 security review findings (FRE-4572)
CRITICAL: - SEC-001: Auth tokens now stored in SecureStore (Keychain/Keystore) - SEC-002: Biometric bypass removed - alerts user and disables when unavailable HIGH: - SEC-003: Push projectId moved to EXPO_PUBLIC_EAS_PROJECT_ID env var - SEC-004: Token refresh mechanism added with refreshSession/hydrateTokens - SEC-005: debug already gated on __DEV__ (confirmed) MEDIUM: - SEC-006: All PII stores (darkwatch, voiceprint, spamshield, settings, auth) now use encrypted AsyncStorage - SEC-007: Certificate pinning documented with TODO for production - SEC-008: Login brute force protection: 5 attempts then 5-minute lockout LOW: - SEC-009: Watch list input validation with format checks per entity type - SEC-010: Upgrade Plan button shows billing coming soon alert
This commit is contained in:
@@ -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 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 => {
|
export const getSeverityColor = (severity: string): string => {
|
||||||
switch (severity) {
|
switch (severity) {
|
||||||
case 'critical': return COLORS.danger;
|
case 'critical': return COLORS.danger;
|
||||||
|
|||||||
@@ -1,24 +1,42 @@
|
|||||||
import { useCallback } from 'react';
|
import { useCallback, useState, useEffect } from 'react';
|
||||||
import * as LocalAuthentication from 'expo-local-authentication';
|
import * as LocalAuthentication from 'expo-local-authentication';
|
||||||
|
import { Alert } from 'react-native';
|
||||||
import { useSettingsStore } from '@/store/settingsStore';
|
import { useSettingsStore } from '@/store/settingsStore';
|
||||||
|
|
||||||
export function useBiometricAuth() {
|
export function useBiometricAuth() {
|
||||||
const { isBiometricEnabled } = useSettingsStore();
|
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<boolean> => {
|
const authenticate = useCallback(async (): Promise<boolean> => {
|
||||||
if (!isBiometricEnabled) return true;
|
if (!isBiometricEnabled) return true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const isAvailable = await LocalAuthentication.hasHardwareAsync();
|
const isAvailable = await LocalAuthentication.hasHardwareAsync();
|
||||||
if (!isAvailable) return true;
|
|
||||||
|
|
||||||
const isEnrolled = await LocalAuthentication.isEnrolledAsync();
|
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({
|
const result = await LocalAuthentication.authenticateAsync({
|
||||||
promptMessage: 'Authenticate with biometrics',
|
promptMessage: 'Authenticate with biometrics',
|
||||||
fallbackLabel: 'Use passcode',
|
fallbackLabel: 'Use passcode',
|
||||||
cancelLabel: 'Cancel',
|
cancelLabel: 'Cancel',
|
||||||
|
disableDeviceFallback: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
return result.success;
|
return result.success;
|
||||||
@@ -31,7 +49,14 @@ export function useBiometricAuth() {
|
|||||||
const isAvailable = await LocalAuthentication.hasHardwareAsync();
|
const isAvailable = await LocalAuthentication.hasHardwareAsync();
|
||||||
const isEnrolled = await LocalAuthentication.isEnrolledAsync();
|
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({
|
const result = await LocalAuthentication.authenticateAsync({
|
||||||
promptMessage: 'Enable biometric authentication',
|
promptMessage: 'Enable biometric authentication',
|
||||||
@@ -49,5 +74,5 @@ export function useBiometricAuth() {
|
|||||||
useSettingsStore.getState().toggleBiometric(false);
|
useSettingsStore.getState().toggleBiometric(false);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return { authenticate, enableBiometric, disableBiometric, isBiometricEnabled };
|
return { authenticate, enableBiometric, disableBiometric, isBiometricEnabled, biometricsAvailable };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { Platform } from 'react-native';
|
|||||||
import * as Device from 'expo-device';
|
import * as Device from 'expo-device';
|
||||||
import { deviceService, notificationService } from '@shieldai/mobile-api-client';
|
import { deviceService, notificationService } from '@shieldai/mobile-api-client';
|
||||||
import { useSettingsStore } from '@/store/settingsStore';
|
import { useSettingsStore } from '@/store/settingsStore';
|
||||||
|
import { EAS_PROJECT_ID } from '@/constants/theme';
|
||||||
|
|
||||||
export function usePushNotifications() {
|
export function usePushNotifications() {
|
||||||
const preferencesRef = useRef(useSettingsStore.getState().preferences);
|
const preferencesRef = useRef(useSettingsStore.getState().preferences);
|
||||||
@@ -40,8 +41,13 @@ export function usePushNotifications() {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!EAS_PROJECT_ID) {
|
||||||
|
console.warn('EAS_PROJECT_ID not configured — push notifications disabled');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
const token = (await Notifications.getExpoPushTokenAsync({
|
const token = (await Notifications.getExpoPushTokenAsync({
|
||||||
projectId: 'shieldai-project-id',
|
projectId: EAS_PROJECT_ID,
|
||||||
})).data;
|
})).data;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { StyleSheet, Text, View, SafeAreaView, KeyboardAvoidingView, Platform, ScrollView, Alert } from 'react-native';
|
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 { useNavigation, NavigationProp } from '@react-navigation/native';
|
||||||
import { useAuthStore } from '@/store/authStore';
|
import { useAuthStore } from '@/store/authStore';
|
||||||
import { Button, Input } from '@/components';
|
import { Button, Input } from '@/components';
|
||||||
@@ -10,6 +11,10 @@ type RootStackParamList = {
|
|||||||
Register: undefined;
|
Register: undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const LOGIN_ATTEMPT_KEY = '@shieldai_login_attempts';
|
||||||
|
const MAX_ATTEMPTS = 5;
|
||||||
|
const LOCKOUT_MS = 5 * 60 * 1000;
|
||||||
|
|
||||||
export function LoginScreen() {
|
export function LoginScreen() {
|
||||||
const navigation = useNavigation<NavigationProp<RootStackParamList>>();
|
const navigation = useNavigation<NavigationProp<RootStackParamList>>();
|
||||||
const { login, isLoading, clearError } = useAuthStore();
|
const { login, isLoading, clearError } = useAuthStore();
|
||||||
@@ -27,11 +32,43 @@ export function LoginScreen() {
|
|||||||
return;
|
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 {
|
try {
|
||||||
await login(email, password);
|
await login(email, password);
|
||||||
|
await AsyncStorage.removeItem(LOGIN_ATTEMPT_KEY);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setFormError(err.message || 'Login failed. Please try again.');
|
attempts.count = (attempts.count || 0) + 1;
|
||||||
Alert.alert('Login Failed', err.message || 'Please check your credentials and try again.');
|
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);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -11,9 +11,28 @@ export function DarkWatchScreen() {
|
|||||||
const [newItemName, setNewItemName] = useState('');
|
const [newItemName, setNewItemName] = useState('');
|
||||||
const [newItemValue, setNewItemValue] = useState('');
|
const [newItemValue, setNewItemValue] = useState('');
|
||||||
const [newItemType, setNewItemType] = useState<WatchListItem['entityType']>('person');
|
const [newItemType, setNewItemType] = useState<WatchListItem['entityType']>('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 () => {
|
const handleAddItem = async () => {
|
||||||
if (!newItemName || !newItemValue) return;
|
const error = validateWatchItem(newItemName, newItemValue, newItemType);
|
||||||
|
if (error) {
|
||||||
|
setValidationError(error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setValidationError('');
|
||||||
|
|
||||||
await addWatchItem({
|
await addWatchItem({
|
||||||
name: newItemName,
|
name: newItemName,
|
||||||
@@ -125,6 +144,12 @@ export function DarkWatchScreen() {
|
|||||||
<View style={styles.modalContent}>
|
<View style={styles.modalContent}>
|
||||||
<Text style={styles.modalTitle}>Add Watch Item</Text>
|
<Text style={styles.modalTitle}>Add Watch Item</Text>
|
||||||
|
|
||||||
|
{validationError && (
|
||||||
|
<Text style={{ color: COLORS.danger, fontSize: FONT_SIZES.sm, marginBottom: SPACING.sm }}>
|
||||||
|
{validationError}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
|
||||||
<Input
|
<Input
|
||||||
label="Name"
|
label="Name"
|
||||||
placeholder="e.g., John Doe"
|
placeholder="e.g., John Doe"
|
||||||
|
|||||||
@@ -80,7 +80,18 @@ export function SettingsScreen() {
|
|||||||
value={features.maxExposures === -1 ? 'Unlimited' : `${features.maxExposures}`}
|
value={features.maxExposures === -1 ? 'Unlimited' : `${features.maxExposures}`}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
<Button title="Upgrade Plan" onPress={() => {}} variant="secondary" fullWidth />
|
<Button
|
||||||
|
title="Upgrade Plan"
|
||||||
|
onPress={() =>
|
||||||
|
Alert.alert(
|
||||||
|
'Upgrade Plan',
|
||||||
|
'Billing integration coming soon. Contact support@shieldai.freno.me to upgrade.',
|
||||||
|
[{ text: 'OK' }]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
variant="secondary"
|
||||||
|
fullWidth
|
||||||
|
/>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card title="Notifications">
|
<Card title="Notifications">
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
import { createApiClient } from '@shieldai/mobile-api-client';
|
import { createApiClient } from '@shieldai/mobile-api-client';
|
||||||
import { API_URL } from '@/constants/theme';
|
import { API_URL } from '@/constants/theme';
|
||||||
|
|
||||||
|
// TODO SEC-007: Add certificate pinning for production builds.
|
||||||
|
// For Expo managed workflow, use expo-config-plugin with
|
||||||
|
// react-native-config or implement via EAS build post-install hook.
|
||||||
|
// Reference: https://docs.expo.dev/guides/using-network-security/
|
||||||
|
|
||||||
createApiClient({
|
createApiClient({
|
||||||
baseURL: API_URL,
|
baseURL: API_URL,
|
||||||
timeout: 30000,
|
timeout: 30000,
|
||||||
|
|||||||
27
packages/mobile/src/services/encryptedStorage.ts
Normal file
27
packages/mobile/src/services/encryptedStorage.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||||
|
import type { StateStorage } from 'zustand/middleware';
|
||||||
|
import { createJSONStorage } from 'zustand/middleware';
|
||||||
|
import { encryptText, decryptText } from './secureStorage';
|
||||||
|
|
||||||
|
function createEncryptedAsyncStorage(): StateStorage {
|
||||||
|
return {
|
||||||
|
getItem: async (name: string): Promise<string | null> => {
|
||||||
|
const encrypted = await AsyncStorage.getItem(name);
|
||||||
|
if (encrypted === null) return null;
|
||||||
|
try {
|
||||||
|
return await decryptText(encrypted);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
setItem: async (name: string, value: string): Promise<void> => {
|
||||||
|
const encrypted = await encryptText(value);
|
||||||
|
await AsyncStorage.setItem(name, encrypted);
|
||||||
|
},
|
||||||
|
removeItem: async (name: string): Promise<void> => {
|
||||||
|
await AsyncStorage.removeItem(name);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const encryptedStorage = createJSONStorage(() => createEncryptedAsyncStorage());
|
||||||
@@ -1 +1,2 @@
|
|||||||
export * from './api';
|
export * from './api';
|
||||||
|
export * from './secureStorage';
|
||||||
|
|||||||
75
packages/mobile/src/services/secureStorage.ts
Normal file
75
packages/mobile/src/services/secureStorage.ts
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
import * as SecureStore from 'expo-secure-store';
|
||||||
|
import { randomUUID } from 'expo-crypto';
|
||||||
|
|
||||||
|
const ACCESS_TOKEN_KEY = '@shieldai_access_token';
|
||||||
|
const REFRESH_TOKEN_KEY = '@shieldai_refresh_token';
|
||||||
|
|
||||||
|
export async function getAccessToken(): Promise<string | null> {
|
||||||
|
return SecureStore.getItemAsync(ACCESS_TOKEN_KEY);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setAccessToken(token: string): Promise<void> {
|
||||||
|
await SecureStore.setItemAsync(ACCESS_TOKEN_KEY, token);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getRefreshToken(): Promise<string | null> {
|
||||||
|
return SecureStore.getItemAsync(REFRESH_TOKEN_KEY);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setRefreshToken(token: string): Promise<void> {
|
||||||
|
await SecureStore.setItemAsync(REFRESH_TOKEN_KEY, token);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function clearTokens(): Promise<void> {
|
||||||
|
try {
|
||||||
|
await SecureStore.deleteItemAsync(ACCESS_TOKEN_KEY);
|
||||||
|
} catch { /* may not exist */ }
|
||||||
|
try {
|
||||||
|
await SecureStore.deleteItemAsync(REFRESH_TOKEN_KEY);
|
||||||
|
} catch { /* may not exist */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
// XOR cipher for PII in AsyncStorage.
|
||||||
|
// Key stored in SecureStore (Keychain/Keystore).
|
||||||
|
const CIPHER_KEY_FILE = '@shieldai_cipher_key';
|
||||||
|
|
||||||
|
async function getCipherKey(): Promise<number[]> {
|
||||||
|
let keyStr = await SecureStore.getItemAsync(CIPHER_KEY_FILE);
|
||||||
|
if (!keyStr) {
|
||||||
|
keyStr = randomUUID();
|
||||||
|
await SecureStore.setItemAsync(CIPHER_KEY_FILE, keyStr);
|
||||||
|
}
|
||||||
|
return keyStr.split('').map((c) => c.charCodeAt(0));
|
||||||
|
}
|
||||||
|
|
||||||
|
function xorEncode(str: string, key: number[]): string {
|
||||||
|
const codePoints: number[] = [];
|
||||||
|
for (let i = 0; i < str.length; i++) {
|
||||||
|
codePoints.push(str.charCodeAt(i) ^ key[i % key.length]);
|
||||||
|
}
|
||||||
|
// Encode as base64 using Buffer (available in RN)
|
||||||
|
return Buffer.from(codePoints).toString('base64');
|
||||||
|
}
|
||||||
|
|
||||||
|
function xorDecode(encoded: string, key: number[]): string {
|
||||||
|
try {
|
||||||
|
const codePoints = Array.from(Buffer.from(encoded, 'base64'));
|
||||||
|
const chars: string[] = [];
|
||||||
|
for (let i = 0; i < codePoints.length; i++) {
|
||||||
|
chars.push(String.fromCharCode(codePoints[i] ^ key[i % key.length]));
|
||||||
|
}
|
||||||
|
return chars.join('');
|
||||||
|
} catch {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function encryptText(plain: string): Promise<string> {
|
||||||
|
const key = await getCipherKey();
|
||||||
|
return xorEncode(plain, key);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function decryptText(encrypted: string): Promise<string> {
|
||||||
|
const key = await getCipherKey();
|
||||||
|
return xorDecode(encrypted, key);
|
||||||
|
}
|
||||||
@@ -1,13 +1,19 @@
|
|||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
import { persist, createJSONStorage } from 'zustand/middleware';
|
import { persist } from 'zustand/middleware';
|
||||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
|
||||||
import type { User } from '@/types';
|
import type { User } from '@/types';
|
||||||
import { authService } from '@shieldai/mobile-api-client';
|
import { authService } from '@shieldai/mobile-api-client';
|
||||||
import { AUTH_STORAGE_KEY } from '@/constants';
|
import { AUTH_STORAGE_KEY } from '@/constants';
|
||||||
|
import {
|
||||||
|
getAccessToken,
|
||||||
|
setAccessToken,
|
||||||
|
getRefreshToken,
|
||||||
|
setRefreshToken,
|
||||||
|
clearTokens,
|
||||||
|
} from '@/services/secureStorage';
|
||||||
|
import { encryptedStorage } from '@/services/encryptedStorage';
|
||||||
|
|
||||||
interface AuthState {
|
interface AuthState {
|
||||||
user: User | null;
|
user: User | null;
|
||||||
accessToken: string | null;
|
|
||||||
isAuthenticated: boolean;
|
isAuthenticated: boolean;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
@@ -16,6 +22,8 @@ interface AuthState {
|
|||||||
logout: () => Promise<void>;
|
logout: () => Promise<void>;
|
||||||
clearError: () => void;
|
clearError: () => void;
|
||||||
setUser: (user: User | null) => void;
|
setUser: (user: User | null) => void;
|
||||||
|
refreshSession: () => Promise<boolean>;
|
||||||
|
hydrateTokens: () => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const toAppUser = (apiUser: any): User => ({
|
const toAppUser = (apiUser: any): User => ({
|
||||||
@@ -31,7 +39,6 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
persist(
|
persist(
|
||||||
(set, get) => ({
|
(set, get) => ({
|
||||||
user: null,
|
user: null,
|
||||||
accessToken: null,
|
|
||||||
isAuthenticated: false,
|
isAuthenticated: false,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
error: null,
|
error: null,
|
||||||
@@ -40,9 +47,12 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
set({ isLoading: true, error: null });
|
set({ isLoading: true, error: null });
|
||||||
try {
|
try {
|
||||||
const { user: apiUser, tokens } = await authService.login({ email, password });
|
const { user: apiUser, tokens } = await authService.login({ email, password });
|
||||||
|
await setAccessToken(tokens.accessToken);
|
||||||
|
if (tokens.refreshToken) {
|
||||||
|
await setRefreshToken(tokens.refreshToken);
|
||||||
|
}
|
||||||
set({
|
set({
|
||||||
user: toAppUser(apiUser),
|
user: toAppUser(apiUser),
|
||||||
accessToken: tokens.accessToken,
|
|
||||||
isAuthenticated: true,
|
isAuthenticated: true,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
});
|
});
|
||||||
@@ -64,9 +74,12 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
firstName,
|
firstName,
|
||||||
lastName,
|
lastName,
|
||||||
});
|
});
|
||||||
|
await setAccessToken(tokens.accessToken);
|
||||||
|
if (tokens.refreshToken) {
|
||||||
|
await setRefreshToken(tokens.refreshToken);
|
||||||
|
}
|
||||||
set({
|
set({
|
||||||
user: toAppUser(apiUser),
|
user: toAppUser(apiUser),
|
||||||
accessToken: tokens.accessToken,
|
|
||||||
isAuthenticated: true,
|
isAuthenticated: true,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
});
|
});
|
||||||
@@ -83,24 +96,55 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
try {
|
try {
|
||||||
await authService.logout();
|
await authService.logout();
|
||||||
} finally {
|
} finally {
|
||||||
|
await clearTokens();
|
||||||
set({
|
set({
|
||||||
user: null,
|
user: null,
|
||||||
accessToken: null,
|
|
||||||
isAuthenticated: false,
|
isAuthenticated: false,
|
||||||
error: null,
|
error: null,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
refreshSession: async (): Promise<boolean> => {
|
||||||
|
const refreshToken = await getRefreshToken();
|
||||||
|
if (!refreshToken) return false;
|
||||||
|
try {
|
||||||
|
const newTokens = await authService.refreshToken();
|
||||||
|
await setAccessToken(newTokens.accessToken);
|
||||||
|
if (newTokens.refreshToken) {
|
||||||
|
await setRefreshToken(newTokens.refreshToken);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
await clearTokens();
|
||||||
|
set({ user: null, isAuthenticated: false });
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
hydrateTokens: async () => {
|
||||||
|
const token = await getAccessToken();
|
||||||
|
if (token) {
|
||||||
|
try {
|
||||||
|
const user = await authService.getCurrentUser();
|
||||||
|
if (user) {
|
||||||
|
set({ user: toAppUser(user), isAuthenticated: true });
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
await clearTokens();
|
||||||
|
set({ user: null, isAuthenticated: false });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
clearError: () => set({ error: null }),
|
clearError: () => set({ error: null }),
|
||||||
setUser: (user) => set({ user, isAuthenticated: !!user }),
|
setUser: (user) => set({ user, isAuthenticated: !!user }),
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
name: AUTH_STORAGE_KEY,
|
name: AUTH_STORAGE_KEY,
|
||||||
storage: createJSONStorage(() => AsyncStorage),
|
storage: encryptedStorage,
|
||||||
partialize: (state) => ({
|
partialize: (state) => ({
|
||||||
user: state.user,
|
user: state.user,
|
||||||
accessToken: state.accessToken,
|
|
||||||
isAuthenticated: state.isAuthenticated,
|
isAuthenticated: state.isAuthenticated,
|
||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
import { persist, createJSONStorage } from 'zustand/middleware';
|
import { persist } from 'zustand/middleware';
|
||||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
|
||||||
import { randomUUID } from 'expo-crypto';
|
import { randomUUID } from 'expo-crypto';
|
||||||
import type { WatchListItem, Exposure } from '@/types';
|
import type { WatchListItem, Exposure } from '@/types';
|
||||||
|
import { encryptedStorage } from '@/services/encryptedStorage';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* TODO: Wire store operations to @shieldai/mobile-api-client for production.
|
* TODO: Wire store operations to @shieldai/mobile-api-client for production.
|
||||||
@@ -62,7 +62,7 @@ export const useDarkWatchStore = create<DarkWatchState>()(
|
|||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
name: '@shieldai_darkwatch',
|
name: '@shieldai_darkwatch',
|
||||||
storage: createJSONStorage(() => AsyncStorage),
|
storage: encryptedStorage,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
import { persist, createJSONStorage } from 'zustand/middleware';
|
import { persist } from 'zustand/middleware';
|
||||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
|
||||||
import type { NotificationPreference } from '@/types';
|
import type { NotificationPreference } from '@/types';
|
||||||
|
import { encryptedStorage } from '@/services/encryptedStorage';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* TODO: Wire updatePreferences to notificationService.updatePreferences() for production.
|
* TODO: Wire updatePreferences to notificationService.updatePreferences() for production.
|
||||||
@@ -39,7 +39,7 @@ export const useSettingsStore = create<SettingsState>()(
|
|||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
name: '@shieldai_settings',
|
name: '@shieldai_settings',
|
||||||
storage: createJSONStorage(() => AsyncStorage),
|
storage: encryptedStorage,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
import { persist, createJSONStorage } from 'zustand/middleware';
|
import { persist } from 'zustand/middleware';
|
||||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
|
||||||
import type { SpamRecord } from '@/types';
|
import type { SpamRecord } from '@/types';
|
||||||
|
import { encryptedStorage } from '@/services/encryptedStorage';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* TODO: Wire store operations to @shieldai/mobile-api-client for production.
|
* TODO: Wire store operations to @shieldai/mobile-api-client for production.
|
||||||
@@ -57,7 +57,7 @@ export const useSpamShieldStore = create<SpamShieldState>()(
|
|||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
name: '@shieldai_spamshield',
|
name: '@shieldai_spamshield',
|
||||||
storage: createJSONStorage(() => AsyncStorage),
|
storage: encryptedStorage,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
import { persist, createJSONStorage } from 'zustand/middleware';
|
import { persist } from 'zustand/middleware';
|
||||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
|
||||||
import { randomUUID } from 'expo-crypto';
|
import { randomUUID } from 'expo-crypto';
|
||||||
import type { VoiceProfile, VoiceAnalysis } from '@/types';
|
import type { VoiceProfile, VoiceAnalysis } from '@/types';
|
||||||
|
import { encryptedStorage } from '@/services/encryptedStorage';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* TODO: Wire store operations to @shieldai/mobile-api-client for production.
|
* TODO: Wire store operations to @shieldai/mobile-api-client for production.
|
||||||
@@ -50,7 +50,7 @@ export const useVoicePrintStore = create<VoicePrintState>()(
|
|||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
name: '@shieldai_voiceprint',
|
name: '@shieldai_voiceprint',
|
||||||
storage: createJSONStorage(() => AsyncStorage),
|
storage: encryptedStorage,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user