import React, { useState, useEffect } from 'react'; import { ScrollView, StyleSheet, View, Alert } from 'react-native'; import { Text, TextInput, Button, Switch, Divider, useTheme, Snackbar, } from 'react-native-paper'; import * as SecureStore from 'expo-secure-store'; import * as LocalAuthentication from 'expo-local-authentication'; import { useAppStore } from '../store/appStore'; import { SECURE_STORE_KEYS } from '../api/client'; /** * Intervalles de polling proposés à l'utilisateur. * Le choix 30s est le défaut — bon compromis batterie/fraîcheur. */ const POLLING_INTERVALS: Array<{ label: string; value: number }> = [ { label: '15s', value: 15_000 }, { label: '30s', value: 30_000 }, { label: '1 min', value: 60_000 }, { label: '5 min', value: 300_000 }, ]; /** * Écran de configuration de l'application. * Gère : * - La connexion à l'instance n8n (URL + clé API + token HAProxy) * - L'activation de l'authentification biométrique * - L'intervalle de polling automatique * * SÉCURITÉ : aucune valeur sensible n'est stockée dans l'état React au-delà de la saisie. * Après sauvegarde, apiKey et appToken sont vidés des champs et ne vivent que dans le secure store. */ const SettingsScreen: React.FC = () => { const theme = useTheme(); const { config, preferences, setConfig, setPreferences } = useAppStore(); const [baseUrl, setBaseUrl] = useState(''); const [apiKey, setApiKey] = useState(''); const [appToken, setAppToken] = useState(''); const [urlError, setUrlError] = useState(''); const [isBiometricAvailable, setIsBiometricAvailable] = useState(false); const [snackMessage, setSnackMessage] = useState(''); const [isSaving, setIsSaving] = useState(false); /** Vérifie la disponibilité du hardware biométrique sur cet appareil */ useEffect(() => { LocalAuthentication.hasHardwareAsync().then(setIsBiometricAvailable); }, []); /** Pré-remplit l'URL depuis le store (jamais la clé API ni le token) */ useEffect(() => { setBaseUrl(config.baseUrl); }, [config.baseUrl]); /** * Valide que l'URL est bien en HTTPS (TLS obligatoire). * L'app refuse toute URL HTTP pour protéger les secrets en transit. * * @param url - URL saisie par l'utilisateur * @returns true si l'URL est valide et en HTTPS */ const validateUrl = (url: string): boolean => { if (!url.startsWith('https://')) { setUrlError("L'URL doit commencer par https:// (TLS obligatoire)"); return false; } try { new URL(url); setUrlError(''); return true; } catch { setUrlError('URL invalide — vérifiez le format'); return false; } }; /** * Sauvegarde la configuration dans le secure store Android Keystore. * Vide les champs sensibles de l'état React immédiatement après sauvegarde. */ const handleSave = async (): Promise => { if (!validateUrl(baseUrl)) return; if (!apiKey.trim()) { setSnackMessage('La clé API n8n est obligatoire'); return; } setIsSaving(true); try { await setConfig(baseUrl.trim(), apiKey.trim(), appToken.trim()); // Nettoyage immédiat des champs sensibles de l'état React setApiKey(''); setAppToken(''); setSnackMessage('Configuration sauvegardée avec succès'); } catch { setSnackMessage('Erreur lors de la sauvegarde'); } finally { setIsSaving(false); } }; /** * Réinitialise complètement la configuration après confirmation explicite. * Supprime toutes les entrées du secure store. */ const handleReset = (): void => { Alert.alert( 'Réinitialiser la configuration', 'Toutes les données de connexion seront supprimées. Cette action est irréversible.', [ { text: 'Annuler', style: 'cancel' }, { text: 'Réinitialiser', style: 'destructive', onPress: async () => { await Promise.all([ SecureStore.deleteItemAsync(SECURE_STORE_KEYS.BASE_URL), SecureStore.deleteItemAsync(SECURE_STORE_KEYS.API_KEY), SecureStore.deleteItemAsync(SECURE_STORE_KEYS.APP_TOKEN), SecureStore.deleteItemAsync(SECURE_STORE_KEYS.BIOMETRIC_ENABLED), ]); setBaseUrl(''); setApiKey(''); setAppToken(''); setSnackMessage('Configuration réinitialisée'); }, }, ] ); }; return ( {/* Section : connexion à l'instance n8n */} Connexion n8n { setBaseUrl(v); if (urlError) validateUrl(v); }} onBlur={() => baseUrl && validateUrl(baseUrl)} error={!!urlError} mode="outlined" style={styles.input} autoCapitalize="none" autoCorrect={false} keyboardType="url" left={} /> {!!urlError && ( {urlError} )} } placeholder={config.isConfigured ? '••••••••••••••••' : undefined} /> } placeholder={config.isConfigured ? '••••••••••••••••' : undefined} /> {/* Section : préférences utilisateur */} Préférences {/* Biométrie — affichée uniquement si le hardware est disponible */} {isBiometricAvailable && ( Déverrouillage biométrique setPreferences({ biometricEnabled: v })} color={theme.colors.primary} /> )} {/* Intervalle de rafraîchissement automatique */} Intervalle de rafraîchissement {POLLING_INTERVALS.map((item) => ( ))} {/* Zone de danger — réinitialisation */} Zone de danger setSnackMessage('')} duration={3000} > {snackMessage} ); }; const styles = StyleSheet.create({ container: { flex: 1, }, content: { padding: 16, paddingBottom: 40, }, sectionTitle: { fontWeight: '600', marginBottom: 12, }, input: { marginBottom: 12, }, saveButton: { marginTop: 4, marginBottom: 8, }, divider: { marginVertical: 20, }, row: { flexDirection: 'row', alignItems: 'center', marginBottom: 16, }, intervalRow: { flexDirection: 'row', gap: 8, flexWrap: 'wrap', }, intervalButton: { flex: 1, minWidth: 60, }, resetButton: { marginTop: 4, }, }); export default SettingsScreen;