feat: application n8n Pilot complète (Expo managed workflow)
- Stack : Expo Router, Axios, Zustand, React Native Paper (thème sombre), date-fns - Sécurité : secrets dans Android Keystore via expo-secure-store, TLS obligatoire, headers X-N8N-API-KEY + X-App-Token injectés par intercepteur Axios - API : client.ts centralisé + workflows.ts + executions.ts (TypeScript strict) - Store : Zustand appStore avec chargement depuis secure store au démarrage - Hooks : usePolling (générique), useWorkflows, useExecutions - Composants : StatusBadge, WorkflowCard, ExecutionCard, SkeletonLoader - Screens : Dashboard, Workflows, Executions, Logs (détail exécution), Settings - Navigation Expo Router : 4 tabs + stack Logs + écran Setup initial - Docs : INSTALL.md, UPDATE.md, BACKUP.md, HAPROXY.md, SECURITY.md Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,329 @@
|
||||
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),
|
||||
]);
|
||||
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;
|
||||
Reference in New Issue
Block a user