Files
n8n-mobile/src/screens/SettingsScreen.tsx
T
billisdead 9c68e7a83f fix: biometric auth, workflow error propagation, execution logs
- Biometric: persist preference to SecureStore so it survives restarts;
  actually call LocalAuthentication.authenticateAsync() at startup and
  block navigation behind a locked screen until the user authenticates
- Workflow run: remove silent error swallowing in triggerWorkflow so
  failures surface as toasts; success snackbar only shown on API success
- Execution logs: add includeData=true to fetchExecutionById so n8n
  returns node-level data and error messages instead of an empty object

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-21 18:34:56 +02:00

331 lines
9.2 KiB
TypeScript

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<void> => {
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 (
<ScrollView
style={[styles.container, { backgroundColor: theme.colors.background }]}
contentContainerStyle={styles.content}
keyboardShouldPersistTaps="handled"
>
{/* Section : connexion à l'instance n8n */}
<Text
variant="titleMedium"
style={[styles.sectionTitle, { color: theme.colors.onBackground }]}
>
Connexion n8n
</Text>
<TextInput
label="URL de l'instance (https://…)"
value={baseUrl}
onChangeText={(v) => {
setBaseUrl(v);
if (urlError) validateUrl(v);
}}
onBlur={() => baseUrl && validateUrl(baseUrl)}
error={!!urlError}
mode="outlined"
style={styles.input}
autoCapitalize="none"
autoCorrect={false}
keyboardType="url"
left={<TextInput.Icon icon="server" />}
/>
{!!urlError && (
<Text
variant="bodySmall"
style={{ color: theme.colors.error, marginBottom: 8, marginLeft: 4 }}
>
{urlError}
</Text>
)}
<TextInput
label="Clé API n8n"
value={apiKey}
onChangeText={setApiKey}
secureTextEntry
mode="outlined"
style={styles.input}
autoCapitalize="none"
autoCorrect={false}
left={<TextInput.Icon icon="key" />}
placeholder={config.isConfigured ? '••••••••••••••••' : undefined}
/>
<TextInput
label="Token HAProxy (X-App-Token)"
value={appToken}
onChangeText={setAppToken}
secureTextEntry
mode="outlined"
style={styles.input}
autoCapitalize="none"
autoCorrect={false}
left={<TextInput.Icon icon="shield-key" />}
placeholder={config.isConfigured ? '••••••••••••••••' : undefined}
/>
<Button
mode="contained"
onPress={handleSave}
loading={isSaving}
disabled={isSaving}
style={styles.saveButton}
>
Sauvegarder
</Button>
<Divider style={styles.divider} />
{/* Section : préférences utilisateur */}
<Text
variant="titleMedium"
style={[styles.sectionTitle, { color: theme.colors.onBackground }]}
>
Préférences
</Text>
{/* Biométrie — affichée uniquement si le hardware est disponible */}
{isBiometricAvailable && (
<View style={styles.row}>
<Text
variant="bodyMedium"
style={{ color: theme.colors.onBackground, flex: 1 }}
>
Déverrouillage biométrique
</Text>
<Switch
value={preferences.biometricEnabled}
onValueChange={(v) => setPreferences({ biometricEnabled: v })}
color={theme.colors.primary}
/>
</View>
)}
{/* Intervalle de rafraîchissement automatique */}
<Text
variant="bodyMedium"
style={{ color: theme.colors.onBackground, marginBottom: 10, marginTop: 8 }}
>
Intervalle de rafraîchissement
</Text>
<View style={styles.intervalRow}>
{POLLING_INTERVALS.map((item) => (
<Button
key={item.value}
mode={preferences.pollingInterval === item.value ? 'contained' : 'outlined'}
onPress={() => setPreferences({ pollingInterval: item.value })}
style={styles.intervalButton}
compact
>
{item.label}
</Button>
))}
</View>
<Divider style={styles.divider} />
{/* Zone de danger — réinitialisation */}
<Text
variant="titleMedium"
style={[styles.sectionTitle, { color: theme.colors.error }]}
>
Zone de danger
</Text>
<Button
mode="outlined"
onPress={handleReset}
textColor={theme.colors.error}
style={[styles.resetButton, { borderColor: theme.colors.error }]}
>
Réinitialiser la configuration
</Button>
<Snackbar
visible={!!snackMessage}
onDismiss={() => setSnackMessage('')}
duration={3000}
>
{snackMessage}
</Snackbar>
</ScrollView>
);
};
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;