9c68e7a83f
- 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>
331 lines
9.2 KiB
TypeScript
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;
|