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:
2026-05-20 17:31:55 +02:00
parent ea1705d3b0
commit 92e67d0769
41 changed files with 4891 additions and 58 deletions
+14
View File
@@ -0,0 +1,14 @@
# Valeurs de développement local UNIQUEMENT
# NE JAMAIS committer .env avec de vraies valeurs
# En production, tous les secrets transitent par expo-secure-store (Android Keystore)
# URL de l'instance n8n — doit utiliser HTTPS en production
N8N_BASE_URL=https://n8n.votre-domaine.com
# Clé API n8n (Settings > API Keys dans l'interface n8n)
# Scope minimal recommandé : lecture workflows + exécutions uniquement
N8N_API_KEY=votre-cle-api-n8n
# Token custom injecté par HAProxy (header X-App-Token)
# Tourner régulièrement selon la procédure dans docs/HAPROXY.md
N8N_APP_TOKEN=votre-token-haproxy
+6 -1
View File
@@ -30,9 +30,14 @@ yarn-error.*
.DS_Store
*.pem
# local env files
# local env files — NE JAMAIS committer les vraies valeurs
.env
.env.local
.env*.local
# EAS credentials — keystore critique, sauvegarder hors git
credentials.json
# typescript
*.tsbuildinfo
-20
View File
@@ -1,20 +0,0 @@
import { StatusBar } from 'expo-status-bar';
import { StyleSheet, Text, View } from 'react-native';
export default function App() {
return (
<View style={styles.container}>
<Text>Open up App.js to start working on your app!</Text>
<StatusBar style="auto" />
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#fff',
alignItems: 'center',
justifyContent: 'center',
},
});
+20 -8
View File
@@ -1,29 +1,41 @@
{
"expo": {
"name": "n8n",
"slug": "n8n",
"name": "n8n Pilot",
"slug": "n8n-pilot",
"scheme": "n8npilot",
"version": "1.0.0",
"orientation": "portrait",
"icon": "./assets/icon.png",
"userInterfaceStyle": "light",
"userInterfaceStyle": "dark",
"newArchEnabled": true,
"splash": {
"image": "./assets/splash-icon.png",
"resizeMode": "contain",
"backgroundColor": "#ffffff"
"backgroundColor": "#121212"
},
"ios": {
"supportsTablet": true
"supportsTablet": false
},
"android": {
"adaptiveIcon": {
"foregroundImage": "./assets/adaptive-icon.png",
"backgroundColor": "#ffffff"
"backgroundColor": "#121212"
},
"edgeToEdgeEnabled": true
"edgeToEdgeEnabled": true,
"package": "com.n8npilot.app"
},
"web": {
"favicon": "./assets/favicon.png"
}
},
"plugins": [
"expo-router",
"expo-secure-store",
[
"expo-local-authentication",
{
"faceIDPermission": "Autoriser n8n Pilot à utiliser Face ID"
}
]
]
}
}
+81
View File
@@ -0,0 +1,81 @@
import React from 'react';
import { Tabs } from 'expo-router';
import { useTheme } from 'react-native-paper';
import { MaterialCommunityIcons } from '@expo/vector-icons';
/**
* Props pour les icônes de tab — typage strict MaterialCommunityIcons.
*/
interface TabIconProps {
name: React.ComponentProps<typeof MaterialCommunityIcons>['name'];
color: string;
size: number;
}
/** Composant d'icône de tab — évite la répétition du rendu inline */
const TabIcon: React.FC<TabIconProps> = ({ name, color, size }) => (
<MaterialCommunityIcons name={name} size={size} color={color} />
);
/**
* Layout de navigation par onglets (4 tabs principaux).
* Les logs d'exécution sont accessibles via le Stack navigator (pas de tab dédié)
* pour garder la nav propre et hiérarchiquement correcte.
*/
export default function TabsLayout() {
const theme = useTheme();
return (
<Tabs
screenOptions={{
tabBarStyle: {
backgroundColor: theme.colors.surface,
borderTopColor: theme.colors.surfaceVariant,
borderTopWidth: 1,
},
tabBarActiveTintColor: theme.colors.primary,
tabBarInactiveTintColor: theme.colors.onSurfaceVariant,
headerStyle: { backgroundColor: theme.colors.surface },
headerTintColor: theme.colors.onSurface,
headerTitleStyle: { color: theme.colors.onSurface },
}}
>
<Tabs.Screen
name="index"
options={{
title: 'Dashboard',
tabBarIcon: ({ color, size }) => (
<TabIcon name="view-dashboard-outline" color={color} size={size} />
),
}}
/>
<Tabs.Screen
name="workflows"
options={{
title: 'Workflows',
tabBarIcon: ({ color, size }) => (
<TabIcon name="sitemap-outline" color={color} size={size} />
),
}}
/>
<Tabs.Screen
name="executions"
options={{
title: 'Exécutions',
tabBarIcon: ({ color, size }) => (
<TabIcon name="history" color={color} size={size} />
),
}}
/>
<Tabs.Screen
name="settings"
options={{
title: 'Paramètres',
tabBarIcon: ({ color, size }) => (
<TabIcon name="cog-outline" color={color} size={size} />
),
}}
/>
</Tabs>
);
}
+3
View File
@@ -0,0 +1,3 @@
import ExecutionsScreen from '../../src/screens/ExecutionsScreen';
export default ExecutionsScreen;
+3
View File
@@ -0,0 +1,3 @@
import DashboardScreen from '../../src/screens/DashboardScreen';
export default DashboardScreen;
+3
View File
@@ -0,0 +1,3 @@
import SettingsScreen from '../../src/screens/SettingsScreen';
export default SettingsScreen;
+3
View File
@@ -0,0 +1,3 @@
import WorkflowsScreen from '../../src/screens/WorkflowsScreen';
export default WorkflowsScreen;
+123
View File
@@ -0,0 +1,123 @@
import React, { useEffect, useState } from 'react';
import { View } from 'react-native';
import { Stack, useRouter, useSegments } from 'expo-router';
import { PaperProvider, MD3DarkTheme, ActivityIndicator, Snackbar } from 'react-native-paper';
import { SafeAreaProvider } from 'react-native-safe-area-context';
import { GestureHandlerRootView } from 'react-native-gesture-handler';
import { useAppStore } from '../src/store/appStore';
import { registerToastCallback } from '../src/utils/errorHandler';
/**
* Thème Material Design 3 sombre personnalisé pour n8n Pilot.
* L'orange (#FF6D3E) est la couleur d'accent principale — cohérente avec l'identité n8n.
*/
const APP_THEME = {
...MD3DarkTheme,
colors: {
...MD3DarkTheme.colors,
primary: '#FF6D3E',
primaryContainer: '#5C1C00',
secondary: '#FF9E7D',
background: '#121212',
surface: '#1E1E1E',
surfaceVariant: '#2C2C2C',
},
};
/**
* Layout racine de l'application.
* Responsabilités :
* 1. Fournit les providers globaux (GestureHandler, SafeArea, Paper)
* 2. Charge la configuration au démarrage depuis le secure store
* 3. Redirige vers /setup si aucune configuration n'existe
* 4. Enregistre le callback de toast pour l'errorHandler global
* 5. Affiche le toast d'erreur global en overlay
*/
export default function RootLayout() {
const router = useRouter();
const segments = useSegments();
const { config, loadConfig } = useAppStore();
const [isReady, setIsReady] = useState(false);
const [toastMessage, setToastMessage] = useState('');
const [toastVisible, setToastVisible] = useState(false);
/**
* Enregistre le callback de toast dans l'errorHandler au premier montage.
* Permet à toute la couche API de remonter des erreurs vers l'UI sans couplage direct.
*/
useEffect(() => {
registerToastCallback((message) => {
setToastMessage(message);
setToastVisible(true);
});
}, []);
/** Charge la configuration depuis le secure store puis marque l'app comme prête */
useEffect(() => {
loadConfig().finally(() => setIsReady(true));
}, []);
/**
* Gère la redirection initiale selon l'état de configuration.
* - Pas configuré et hors /setup → redirige vers /setup
* - Configuré et dans /setup → redirige vers les tabs
*/
useEffect(() => {
if (!isReady) return;
const inSetup = segments[0] === 'setup';
if (!config.isConfigured && !inSetup) {
router.replace('/setup');
} else if (config.isConfigured && inSetup) {
router.replace('/(tabs)');
}
}, [isReady, config.isConfigured, segments]);
if (!isReady) {
return (
<View style={{ flex: 1, alignItems: 'center', justifyContent: 'center', backgroundColor: '#121212' }}>
<ActivityIndicator color="#FF6D3E" size="large" />
</View>
);
}
return (
<GestureHandlerRootView style={{ flex: 1 }}>
<SafeAreaProvider>
<PaperProvider theme={APP_THEME}>
<Stack screenOptions={{ headerShown: false }}>
<Stack.Screen name="(tabs)" />
<Stack.Screen
name="setup"
options={{
headerShown: true,
headerTitle: 'Configuration',
headerStyle: { backgroundColor: '#1E1E1E' },
headerTintColor: '#FF6D3E',
}}
/>
<Stack.Screen
name="execution/[id]"
options={{
headerShown: true,
headerTitle: "Logs d'exécution",
headerStyle: { backgroundColor: '#1E1E1E' },
headerTintColor: '#FF6D3E',
presentation: 'card',
}}
/>
</Stack>
{/* Toast global pour toutes les erreurs remontées par l'errorHandler */}
<Snackbar
visible={toastVisible}
onDismiss={() => setToastVisible(false)}
duration={4000}
style={{ backgroundColor: '#4A0000' }}
>
{toastMessage}
</Snackbar>
</PaperProvider>
</SafeAreaProvider>
</GestureHandlerRootView>
);
}
+3
View File
@@ -0,0 +1,3 @@
import LogsScreen from '../../src/screens/LogsScreen';
export default LogsScreen;
+49
View File
@@ -0,0 +1,49 @@
import React from 'react';
import { View, StyleSheet } from 'react-native';
import { Text, useTheme } from 'react-native-paper';
import SettingsScreen from '../src/screens/SettingsScreen';
/**
* Écran d'installation initiale — affiché au premier lancement.
* Partage le composant SettingsScreen avec un en-tête de bienvenue.
* Après sauvegarde, le root layout redirige automatiquement vers les tabs.
*/
export default function SetupScreen() {
const theme = useTheme();
return (
<View style={[styles.container, { backgroundColor: theme.colors.background }]}>
<View style={styles.header}>
<Text
variant="headlineMedium"
style={[styles.title, { color: theme.colors.primary }]}
>
n8n Pilot
</Text>
<Text
variant="bodyMedium"
style={{ color: theme.colors.onSurfaceVariant, textAlign: 'center' }}
>
Configurez votre instance n8n pour commencer
</Text>
</View>
<SettingsScreen />
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
},
header: {
paddingHorizontal: 24,
paddingTop: 24,
paddingBottom: 8,
alignItems: 'center',
gap: 4,
},
title: {
fontWeight: '700',
},
});
+7
View File
@@ -0,0 +1,7 @@
module.exports = function (api) {
api.cache(true);
return {
presets: ['babel-preset-expo'],
plugins: ['react-native-reanimated/plugin'],
};
};
+89
View File
@@ -0,0 +1,89 @@
# Sauvegarde de n8n Pilot
## Ce qu'il faut sauvegarder
### 1. Credentials de signature EAS (`credentials.json`)
**Où** : généré par EAS lors du premier build dans `~/.eas/` ou téléchargeable depuis le dashboard Expo.
**Pourquoi** : sans le keystore de signature, il est **impossible** de mettre à jour l'APK sur un appareil
qui a déjà la version signée avec ce keystore. Android refuse les mises à jour avec un keystore différent.
**Comment sauvegarder** :
```bash
# Télécharger les credentials depuis EAS
eas credentials
# Choisir : Android > Keystore > Download
# Sauvegarder le fichier .jks et les mots de passe associés
```
> **Criticalité : MAXIMALE** — perte du keystore = obligation de désinstaller l'app sur tous les appareils
> avant de pouvoir installer une nouvelle version.
---
### 2. Fichier `.env` de développement
**Où** : racine du projet (jamais dans git)
**Pourquoi** : contient l'URL de l'instance, la clé API et le token HAProxy pour l'environnement de dev.
Sans ce fichier, les développeurs doivent reconfigurer manuellement l'environnement.
**Comment sauvegarder** : gestionnaire de mots de passe (Bitwarden, 1Password, Vault) avec les champs :
- `N8N_BASE_URL`
- `N8N_API_KEY`
- `N8N_APP_TOKEN`
---
### 3. Configuration EAS (`eas.json`)
**Où** : racine du projet, **committé dans git**
**Pourquoi** : définit les profils de build (development, preview, production).
**Statut** : déjà sauvegardé via git — aucune action supplémentaire requise.
---
### 4. Données utilisateur sur l'appareil
Les credentials (URL, clé API, token) sont chiffrés dans l'**Android Keystore** de l'appareil.
Ils ne sont **pas** sauvegardés dans les sauvegardes Android standard (par conception de sécurité).
**Conséquence** : après réinitialisation usine ou changement d'appareil, l'utilisateur doit
reconfigurer l'app depuis l'écran Paramètres.
---
## Fréquence recommandée
| Élément | Fréquence | Stockage |
|---------|-----------|----------|
| Keystore EAS | À chaque rotation + après génération | Coffre chiffré hors-ligne |
| `.env` dev | À chaque rotation des tokens | Gestionnaire de mots de passe |
| Code source | En continu via git push | Gitea self-hosted |
---
## Procédure de restauration
### Restaurer le keystore
```bash
# Uploader le keystore sauvegardé vers EAS
eas credentials
# Choisir : Android > Keystore > Upload existing keystore
```
### Restaurer l'environnement de développement
```bash
git clone https://homegit.gyozamancave.fr/billisdead/n8n-mobile.git
cd n8n-mobile
cp .env.example .env
# Remplir .env depuis le gestionnaire de mots de passe
npm install --legacy-peer-deps
```
+195
View File
@@ -0,0 +1,195 @@
# Configuration HAProxy pour n8n Pilot
## Architecture
```
Android App → HTTPS → HAProxy (443) → HTTP → n8n (5678)
Validation X-App-Token
Rate limiting
TLS termination
```
HAProxy agit comme reverse proxy devant n8n. Il :
1. Termine TLS (certificat Let's Encrypt ou autosigné)
2. Valide le header `X-App-Token` (token secret partagé)
3. Applique du rate limiting pour protéger l'API
4. Forward les requêtes vers n8n en HTTP interne
---
## Configuration HAProxy complète
```haproxy
#---------------------------------------------------------------------
# global : paramètres du processus HAProxy
#---------------------------------------------------------------------
global
log /dev/log local0
log /dev/log local1 notice
maxconn 2000
daemon
#---------------------------------------------------------------------
# defaults : valeurs par défaut pour tous les frontends/backends
#---------------------------------------------------------------------
defaults
log global
mode http
option httplog
option dontlognull
timeout connect 5s
timeout client 30s
timeout server 30s
# Timeout plus long pour les webhooks n8n qui peuvent prendre du temps
timeout tunnel 3600s
#---------------------------------------------------------------------
# frontend : point d'entrée HTTPS sur le port 443
#---------------------------------------------------------------------
frontend n8n_pilot_frontend
bind *:443 ssl crt /etc/ssl/n8n/fullchain.pem alpn h2,http/1.1
# Sécurité TLS : désactiver les versions obsolètes
ssl-min-ver TLSv1.2
# Headers de sécurité injectés sur toutes les réponses
http-response set-header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload"
http-response set-header X-Content-Type-Options "nosniff"
http-response set-header X-Frame-Options "DENY"
# ACL : vérification du X-App-Token
# La valeur doit correspondre exactement au token configuré dans l'app
acl valid_app_token req.hdr(X-App-Token) -m str "VOTRE_TOKEN_ICI"
# Refuser les requêtes sans token valide avec 403
http-request deny deny_status 403 if !valid_app_token
# Rate limiting : max 60 requêtes par minute par IP
# Protège contre les scripts de scan et les abus
stick-table type ip size 100k expire 60s store http_req_rate(60s)
http-request track-sc0 src
http-request deny deny_status 429 if { sc_http_req_rate(0) gt 60 }
# Redirection HTTP → HTTPS (optionnel si port 80 ouvert)
# redirect scheme https if !{ ssl_fc }
default_backend n8n_backend
#---------------------------------------------------------------------
# backend : instance n8n locale
#---------------------------------------------------------------------
backend n8n_backend
balance roundrobin
# Supprimer le X-App-Token avant de forwarder à n8n (ne doit pas atteindre n8n)
http-request del-header X-App-Token
# Health check : n8n répond sur /healthz
option httpchk GET /healthz
http-check expect status 200
server n8n_local 127.0.0.1:5678 check inter 10s fall 3 rise 2
```
---
## Rotation du X-App-Token
Le token doit être tourné régulièrement (recommandé : tous les 90 jours minimum).
### Procédure de rotation sans downtime
1. **Générer un nouveau token** :
```bash
openssl rand -hex 32
# Exemple : a3f8b2c1d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1
```
2. **Phase de transition — accepter les deux tokens** :
```haproxy
# Accepter l'ancien ET le nouveau pendant la transition
acl valid_app_token req.hdr(X-App-Token) -m str "ANCIEN_TOKEN"
acl valid_app_token_new req.hdr(X-App-Token) -m str "NOUVEAU_TOKEN"
http-request deny deny_status 403 if !valid_app_token !valid_app_token_new
```
3. **Mettre à jour l'app** sur tous les appareils (Paramètres > Token HAProxy > Sauvegarder)
4. **Retirer l'ancien token** de HAProxy une fois tous les appareils mis à jour :
```haproxy
acl valid_app_token req.hdr(X-App-Token) -m str "NOUVEAU_TOKEN"
http-request deny deny_status 403 if !valid_app_token
```
5. **Recharger HAProxy** sans coupure :
```bash
haproxy -f /etc/haproxy/haproxy.cfg -c # Valider la config
systemctl reload haproxy # Rechargement gracieux
```
---
## Troubleshooting
### Erreur 403 Forbidden
**Cause** : `X-App-Token` absent ou incorrect.
**Diagnostic** :
```bash
# Tester manuellement avec curl
curl -v -H "X-App-Token: VOTRE_TOKEN" https://n8n.votre-domaine.com/api/v1/workflows
```
**Solutions** :
- Vérifier que le token dans l'app (Paramètres) correspond exactement à la config HAProxy
- Contrôler les espaces ou caractères invisibles dans le token
- Vérifier les logs HAProxy : `journalctl -u haproxy -f`
---
### Erreur 429 Too Many Requests
**Cause** : rate limit dépassé (> 60 req/min).
**Solutions** :
- Augmenter l'intervalle de polling dans l'app (Paramètres > Intervalle)
- Ajuster la limite dans HAProxy si l'usage légitime est plus élevé
- Vérifier qu'aucun processus tiers ne spam l'endpoint
---
### Timeout / connexion refusée
**Cause** : n8n inaccessible sur le port 5678, ou HAProxy mal configuré.
**Diagnostic** :
```bash
# Vérifier que n8n tourne
curl http://127.0.0.1:5678/healthz
# Vérifier le statut HAProxy
systemctl status haproxy
haproxy -f /etc/haproxy/haproxy.cfg -c
# Logs HAProxy en temps réel
journalctl -u haproxy -f
```
---
### Certificat TLS expiré
```bash
# Renouveler avec Certbot
certbot renew --quiet
# Recharger HAProxy pour prendre en compte le nouveau certificat
systemctl reload haproxy
```
Automatiser le renouvellement :
```bash
# Ajouter dans crontab (root)
0 3 * * 1 certbot renew --quiet && systemctl reload haproxy
```
+136
View File
@@ -0,0 +1,136 @@
# Installation de n8n Pilot
## Prérequis
| Outil | Version minimale | Rôle |
|-------|-----------------|------|
| Node.js | 20 LTS | Runtime JS |
| npm | 10+ | Gestionnaire de paquets |
| EAS CLI | 3.0+ | Build cloud Expo |
| Android | 8.0 (API 26) | Cible minimum |
| Compte Expo | gratuit | Build EAS |
```bash
# Vérifier les prérequis
node --version # >= 20
npm --version # >= 10
eas --version # >= 3.0 (après install)
```
---
## 1. Cloner le dépôt
```bash
git clone https://homegit.gyozamancave.fr/billisdead/n8n-mobile.git
cd n8n-mobile
```
---
## 2. Installer les dépendances
```bash
npm install --legacy-peer-deps
```
---
## 3. Configurer l'environnement de développement
Copiez le fichier exemple et remplissez vos valeurs **localement uniquement** :
```bash
cp .env.example .env
# Éditez .env avec votre URL, clé API et token HAProxy
```
> ⚠️ Le fichier `.env` est dans `.gitignore` — ne jamais le committer.
> En production, les secrets transitent **uniquement** par `expo-secure-store`.
---
## 4. Lancer en développement
```bash
# Démarrer le serveur Metro
npm start
# Sur appareil Android (USB ou WiFi) ou émulateur
npm run android
```
Pour le développement, l'app vous demandera de saisir l'URL et la clé API
directement dans l'écran Paramètres — elles seront stockées dans le Keystore Android.
---
## 5. Installer EAS CLI
```bash
npm install -g eas-cli
eas login # Connectez-vous avec votre compte Expo
```
---
## 6. Configurer le projet EAS
```bash
# Initialise l'ID de projet Expo (première fois uniquement)
eas init
# Vérifie la configuration
eas build:configure
```
---
## 7. Builder l'APK (profil preview)
```bash
# Build Android APK — exécuté dans le cloud Expo
eas build --platform android --profile preview
```
Le build prend environ 1015 minutes. EAS envoie un email avec le lien de téléchargement.
Pour un build local (nécessite Android Studio + JDK 17) :
```bash
eas build --platform android --profile preview --local
```
---
## 8. Sideloader l'APK sur Android
### Via ADB
```bash
# Connecter l'appareil en USB avec le débogage USB activé
adb devices
# Installer l'APK
adb install chemin/vers/n8n-pilot.apk
```
### Manuellement
1. Transférez l'APK sur l'appareil (câble, Google Drive, etc.)
2. Ouvrez l'APK depuis le gestionnaire de fichiers
3. Autorisez l'installation depuis des sources inconnues si demandé
4. Suivez l'assistant d'installation
---
## 9. Premier lancement
Au premier démarrage, l'écran de configuration apparaît automatiquement :
1. Saisissez l'URL HTTPS de votre instance n8n
2. Collez votre clé API n8n (Settings > API Keys dans n8n)
3. Saisissez le token HAProxy si applicable
4. Appuyez sur **Sauvegarder**
Les credentials sont chiffrés dans l'Android Keystore — ils ne quittent jamais l'appareil.
+147
View File
@@ -0,0 +1,147 @@
# Sécurité — n8n Pilot
## Modèle de menaces
### Acteurs et vecteurs
| Menace | Impact | Probabilité | Mitigation |
|--------|--------|-------------|-----------|
| Vol de l'appareil Android | Accès aux credentials n8n | Moyen | Android Keystore chiffré, biométrie optionnelle |
| Interception réseau (MITM) | Exposition de la clé API | Faible (TLS) | TLS obligatoire, refus HTTP |
| Fuite du token HAProxy | Accès non autorisé à n8n | Moyen | Rotation régulière, token hors bundle |
| Compromission du build APK | Backdoor dans l'app | Faible | Keystore signé, builds EAS reproductibles |
| Brute force API | Épuisement quotas n8n | Faible | Rate limiting HAProxy (429) |
| Logs applicatifs exposés | Fuite de secrets | Faible | Jamais de log de secrets, même en dev |
---
## Principes appliqués
### Least Privilege (moindre privilège)
La clé API n8n doit être créée avec le **scope minimal** nécessaire :
```
Recommandé :
✓ workflows:list — lister les workflows
✓ workflows:read — lire les détails
✓ workflows:update — activer/désactiver (toggle)
✓ executions:list — voir l'historique
✓ executions:read — lire les logs
✓ executions:delete — supprimer des exécutions
✓ workflows:run — déclencher manuellement
Non recommandé :
✗ credentials:* — inutile pour cette app
✗ users:* — inutile pour cette app
✗ admin:* — jamais
```
**Comment créer la clé** dans n8n :
1. Settings > n8n API
2. Create API Key
3. Nommer la clé "n8n-pilot-mobile"
4. Définir les scopes minimaux ci-dessus
5. Copier la clé — elle n'est affichée qu'une seule fois
---
### Defense in Depth (défense en profondeur)
L'accès à l'API n8n passe par **deux barrières indépendantes** :
```
Requête → [TLS] → [X-App-Token HAProxy] → [X-N8N-API-KEY n8n] → Données
```
Si un token est compromis :
- Compromission du `X-App-Token` seul → HAProxy bloque, n8n jamais atteint
- Compromission de la clé API seul → sans le `X-App-Token`, HAProxy bloque à 403
- Les deux compromis → rotation immédiate des deux (procédures dans HAPROXY.md et BACKUP.md)
---
### Zero Trust (réseau local inclus)
n8n ne doit **pas** être accessible directement sur le réseau, même en local.
Toutes les requêtes passent par HAProxy qui valide le token.
```
# Bloquer l'accès direct au port n8n (iptables)
iptables -A INPUT -p tcp --dport 5678 -s 127.0.0.1 -j ACCEPT
iptables -A INPUT -p tcp --dport 5678 -j DROP
```
---
## Gestion des secrets — cycle de vie
### Stockage
```
┌─────────────────────────────────────────────────────┐
│ Appareil Android │
│ ┌─────────────────────────────────────────────┐ │
│ │ Android Keystore (expo-secure-store) │ │
│ │ n8n_base_url → https://n8n.example.com │ │
│ │ n8n_api_key → clé API (chiffrée) │ │
│ │ n8n_app_token → token HAProxy (chiffré) │ │
│ └─────────────────────────────────────────────┘ │
│ │
│ État React (Zustand) — en mémoire uniquement : │
│ config.baseUrl → copie non sensible │
│ config.isConfigured → booléen │
│ JAMAIS : apiKey, appToken en mémoire JS │
└─────────────────────────────────────────────────────┘
```
### Transit
- Les secrets sont lus depuis le Keystore **à chaque requête** HTTP dans `client.ts`
- Ils sont injectés dans les headers HTTP et **ne sont pas loggués**
- Ils ne sont **jamais** :
- Sérialisés dans AsyncStorage
- Inclus dans des logs (même en dev)
- Embarqués dans le bundle JS (`app.json`, `package.json`, code source)
- Envoyés à des services tiers (analytics, crash reporters)
### Rotation
| Secret | Fréquence recommandée | Procédure |
|--------|-----------------------|-----------|
| Clé API n8n | Tous les 180 jours, ou en cas de compromission | Créer nouvelle clé dans n8n, mettre à jour dans l'app |
| X-App-Token | Tous les 90 jours | Voir docs/HAPROXY.md |
| Keystore EAS | Jamais (sauf compromission) | Voir docs/BACKUP.md |
---
## Checklist avant mise en production
### Application
- [ ] `app.json` : `userInterfaceStyle` = `"dark"` (pas d'info sensible en clair)
- [ ] `.env` non commité dans git (`git status` ne montre pas `.env`)
- [ ] Aucun `console.log` de valeurs sensibles dans le code
- [ ] `__DEV__` gate sur tous les logs dans `errorHandler.ts`
- [ ] Expo SDK à jour — vérifier les CVE : `npx expo-doctor`
- [ ] Dépendances à jour : `npm audit`
### Infrastructure
- [ ] HAProxy : TLS 1.2 minimum, TLS 1.0/1.1 désactivés
- [ ] Certificat TLS valide et non expiré
- [ ] n8n inaccessible directement sur le port 5678 depuis l'extérieur
- [ ] Rate limiting HAProxy activé (429 configuré)
- [ ] Logs HAProxy ne contiennent pas les valeurs des headers secrets
### n8n
- [ ] Clé API avec scope minimal (voir § Least Privilege ci-dessus)
- [ ] n8n mis à jour vers la dernière version stable
- [ ] Sauvegardes n8n actives (workflows, credentials n8n)
### Appareil
- [ ] Verrouillage d'écran activé (PIN, pattern ou biométrie)
- [ ] Biométrie configurée dans l'app si disponible
- [ ] Appareil Android 8+ (API 26+) pour garantir la robustesse du Keystore
+78
View File
@@ -0,0 +1,78 @@
# Mise à jour de n8n Pilot
## Processus de mise à jour
### 1. Récupérer les changements
```bash
git pull origin main
npm install --legacy-peer-deps
```
### 2. Bumper la version
Éditez `app.json` et incrémentez `version` :
```json
{
"expo": {
"version": "1.1.0",
"android": {
"versionCode": 2
}
}
}
```
> **Règle** : `versionCode` doit être strictement supérieur à la version précédente.
> `version` suit [semver](https://semver.org/) : MAJEUR.MINEUR.CORRECTIF
### 3. Rebuilder l'APK
```bash
eas build --platform android --profile preview
```
### 4. Distribuer la mise à jour
#### Option A — Sideload manuel
Répétez la procédure de sideload (voir INSTALL.md § 8) avec le nouvel APK.
L'Android Package Manager gère la mise à jour en place si le `versionCode` est supérieur.
#### Option B — OTA (Over The Air) via Expo Updates
Pour les mises à jour mineures (JS uniquement, pas de changements natifs) :
```bash
eas update --branch production --message "Fix: correction du polling"
```
L'app télécharge la mise à jour au prochain démarrage si elle est connectée.
> **Important** : les changements dans `app.json`, l'ajout de plugins natifs
> ou la modification des permissions nécessitent un rebuild APK complet.
---
## Vérifications avant release
- [ ] Tests sur appareil physique (pas seulement émulateur)
- [ ] Vérifier que la connexion à l8n fonctionne avec les nouvelles données
- [ ] Valider le polling sur WiFi et données mobiles
- [ ] Vérifier que les secrets restent dans le Keystore après mise à jour
- [ ] Contrôler les logs Android (`adb logcat`) pour des erreurs inattendues
---
## Rollback
En cas de régression critique :
```bash
# Revenir au commit précédent
git revert HEAD
# Rebuilder et redéployer la version précédente
eas build --platform android --profile preview
```
+29
View File
@@ -0,0 +1,29 @@
{
"cli": {
"version": ">= 3.0.0"
},
"build": {
"development": {
"developmentClient": true,
"distribution": "internal",
"android": {
"buildType": "apk"
}
},
"preview": {
"distribution": "internal",
"android": {
"buildType": "apk",
"gradleCommand": ":app:assembleRelease"
}
},
"production": {
"android": {
"buildType": "apk"
}
}
},
"submit": {
"production": {}
}
}
-8
View File
@@ -1,8 +0,0 @@
import { registerRootComponent } from 'expo';
import App from './App';
// registerRootComponent calls AppRegistry.registerComponent('main', () => App);
// It also ensures that whether you load the app in Expo Go or in a native build,
// the environment is set up appropriately
registerRootComponent(App);
+1629 -17
View File
File diff suppressed because it is too large Load Diff
+20 -4
View File
@@ -1,7 +1,7 @@
{
"name": "n8n",
"name": "n8n-pilot",
"version": "1.0.0",
"main": "index.js",
"main": "expo-router/entry",
"scripts": {
"start": "expo start",
"android": "expo start --android",
@@ -9,10 +9,26 @@
"web": "expo start --web"
},
"dependencies": {
"axios": "^1.16.1",
"date-fns": "^4.2.1",
"expo": "~54.0.33",
"expo-local-authentication": "~17.0.8",
"expo-router": "~6.0.23",
"expo-secure-store": "~15.0.8",
"expo-status-bar": "~3.0.9",
"react": "19.1.0",
"react-native": "0.81.5"
"react-native": "0.81.5",
"react-native-gesture-handler": "~2.28.0",
"react-native-paper": "^5.15.2",
"react-native-reanimated": "~4.1.1",
"react-native-safe-area-context": "~5.6.0",
"react-native-screens": "~4.16.0",
"zustand": "^5.0.13"
},
"private": true
"private": true,
"devDependencies": {
"@types/react": "^19.2.15",
"@types/react-native": "^0.72.8",
"typescript": "^6.0.3"
}
}
+77
View File
@@ -0,0 +1,77 @@
import axios, {
AxiosInstance,
InternalAxiosRequestConfig,
AxiosResponse,
AxiosError,
} from 'axios';
import * as SecureStore from 'expo-secure-store';
import { handleError } from '../utils/errorHandler';
/**
* Clés de stockage sécurisé Android Keystore via expo-secure-store.
* Ces constantes sont les seuls points de référence aux secrets dans toute l'app.
*/
export const SECURE_STORE_KEYS = {
BASE_URL: 'n8n_base_url',
API_KEY: 'n8n_api_key',
APP_TOKEN: 'n8n_app_token',
} as const;
/**
* Crée et configure l'instance Axios centrale de l'application.
*
* Architecture de sécurité :
* - Les credentials (API key, app token) ne sont JAMAIS embarqués dans le bundle.
* - Ils sont récupérés dynamiquement depuis le secure store à chaque requête.
* - La base URL est également stockée dans le secure store, pas en dur.
*/
const createApiClient = (): AxiosInstance => {
const client = axios.create({
timeout: 15_000,
headers: {
'Content-Type': 'application/json',
},
});
/**
* Intercepteur de requête sortante.
* Lit baseURL, X-N8N-API-KEY et X-App-Token depuis le secure store
* et les injecte dans chaque requête avant envoi.
* Aucune valeur sensible ne transite par les variables d'environnement du bundle.
*/
client.interceptors.request.use(
async (config: InternalAxiosRequestConfig): Promise<InternalAxiosRequestConfig> => {
const [baseUrl, apiKey, appToken] = await Promise.all([
SecureStore.getItemAsync(SECURE_STORE_KEYS.BASE_URL),
SecureStore.getItemAsync(SECURE_STORE_KEYS.API_KEY),
SecureStore.getItemAsync(SECURE_STORE_KEYS.APP_TOKEN),
]);
if (baseUrl) config.baseURL = baseUrl;
if (apiKey) config.headers.set('X-N8N-API-KEY', apiKey);
if (appToken) config.headers.set('X-App-Token', appToken);
return config;
},
(error: AxiosError) => Promise.reject(error)
);
/**
* Intercepteur de réponse.
* Les erreurs HTTP sont transmises à errorHandler qui affiche un toast.
* L'erreur est re-rejetée pour que les appelants puissent aussi réagir si besoin.
* Aucun secret n'est loggué ici.
*/
client.interceptors.response.use(
(response: AxiosResponse) => response,
(error: AxiosError) => {
handleError(error);
return Promise.reject(error);
}
);
return client;
};
/** Instance Axios unique partagée dans toute l'application */
export const apiClient = createApiClient();
+107
View File
@@ -0,0 +1,107 @@
import { apiClient } from './client';
import { ApiListResponse } from './workflows';
/** Ensemble des statuts possibles pour une exécution n8n */
export type ExecutionStatus = 'success' | 'error' | 'waiting' | 'running' | 'canceled';
/** Modes de déclenchement d'une exécution */
export type ExecutionMode = 'manual' | 'trigger' | 'webhook' | 'internal' | 'retry';
/**
* Données d'exécution d'un nœud individuel.
* Disponibles uniquement dans la réponse détaillée (GET /executions/:id).
*/
export interface NodeExecutionData {
startTime: number;
executionTime: number;
source: unknown[];
data?: Record<string, unknown>;
error?: {
message: string;
name?: string;
stack?: string;
};
}
/** Données complètes d'un résultat d'exécution */
export interface ExecutionResultData {
resultData: {
/** Map nœud → tableau des runs de ce nœud */
runData: Record<string, NodeExecutionData[]>;
lastNodeExecuted?: string;
/** Erreur globale de l'exécution (distinct des erreurs par nœud) */
error?: {
message: string;
name: string;
stack?: string;
};
};
}
/**
* Représente une exécution telle que retournée par l'API n8n v1.
* Le champ `data` n'est disponible que dans la réponse de GET /executions/:id.
*/
export interface Execution {
id: string;
finished: boolean;
mode: ExecutionMode;
/** ID de l'exécution originale si c'est un retry */
retryOf?: string;
retrySuccessId?: string;
startedAt: string;
stoppedAt?: string;
workflowId: string;
workflowName?: string;
status: ExecutionStatus;
/** Présent uniquement via GET /executions/:id */
data?: ExecutionResultData;
}
/** Paramètres de filtrage acceptés par l'endpoint GET /executions */
export interface FetchExecutionsParams {
/** Nombre max de résultats à retourner */
limit?: number;
/** Filtre par statut d'exécution */
status?: ExecutionStatus;
/** Filtre par identifiant de workflow */
workflowId?: string;
/** Curseur opaque de pagination (valeur nextCursor de la réponse précédente) */
cursor?: string;
}
/**
* Récupère l'historique des exécutions avec filtres optionnels.
*
* @param params - Filtres : limit, status, workflowId, cursor
* @returns Réponse paginée avec les exécutions et le curseur suivant
*/
export const fetchExecutions = async (
params: FetchExecutionsParams = {}
): Promise<ApiListResponse<Execution>> => {
const response = await apiClient.get<ApiListResponse<Execution>>('/api/v1/executions', {
params,
});
return response.data;
};
/**
* Récupère les logs complets d'une exécution spécifique.
* Inclut les données de chaque nœud (champ `data`), absent dans la liste.
*
* @param id - Identifiant de l'exécution
* @returns Exécution complète avec données de nœuds
*/
export const fetchExecutionById = async (id: string): Promise<Execution> => {
const response = await apiClient.get<Execution>(`/api/v1/executions/${id}`);
return response.data;
};
/**
* Supprime une exécution de l'historique n8n.
*
* @param id - Identifiant de l'exécution à supprimer
*/
export const deleteExecution = async (id: string): Promise<void> => {
await apiClient.delete(`/api/v1/executions/${id}`);
};
+112
View File
@@ -0,0 +1,112 @@
import { apiClient } from './client';
/** Représente un nœud dans un workflow n8n */
export interface WorkflowNode {
id: string;
name: string;
type: string;
position: [number, number];
parameters: Record<string, unknown>;
typeVersion?: number;
}
/** Tag associé à un workflow */
export interface WorkflowTag {
id: string;
name: string;
createdAt: string;
updatedAt: string;
}
/**
* Représente un workflow complet tel que retourné par l'API n8n v1.
* Les champs `nodes` et `connections` sont présents dans les réponses détaillées.
*/
export interface Workflow {
id: string;
name: string;
active: boolean;
createdAt: string;
updatedAt: string;
nodes: WorkflowNode[];
connections: Record<string, unknown>;
settings?: Record<string, unknown>;
tags?: WorkflowTag[];
}
/**
* Réponse paginée standard de l'API n8n.
* nextCursor est null si on est sur la dernière page.
*/
export interface ApiListResponse<T> {
data: T[];
nextCursor: string | null;
}
/**
* Récupère la liste complète des workflows de l'instance n8n.
*
* @returns Tableau de tous les workflows (actifs et inactifs)
* @throws AxiosError — géré par l'intercepteur, remonte via errorHandler
*/
export const fetchWorkflows = async (): Promise<Workflow[]> => {
const response = await apiClient.get<ApiListResponse<Workflow>>('/api/v1/workflows');
return response.data.data;
};
/**
* Récupère le détail complet d'un workflow, incluant ses nœuds et connexions.
*
* @param id - Identifiant du workflow
* @returns Workflow avec tous ses détails
*/
export const fetchWorkflowById = async (id: string): Promise<Workflow> => {
const response = await apiClient.get<Workflow>(`/api/v1/workflows/${id}`);
return response.data;
};
/**
* Active un workflow : il répondra désormais à ses déclencheurs.
*
* @param id - Identifiant du workflow à activer
* @returns Workflow mis à jour avec active = true
*/
export const activateWorkflow = async (id: string): Promise<Workflow> => {
const response = await apiClient.post<Workflow>(`/api/v1/workflows/${id}/activate`);
return response.data;
};
/**
* Désactive un workflow : ses déclencheurs sont mis en veille.
*
* @param id - Identifiant du workflow à désactiver
* @returns Workflow mis à jour avec active = false
*/
export const deactivateWorkflow = async (id: string): Promise<Workflow> => {
const response = await apiClient.post<Workflow>(`/api/v1/workflows/${id}/deactivate`);
return response.data;
};
/** Paramètres optionnels pour le déclenchement manuel d'un workflow */
export interface RunWorkflowParams {
/** Données injectées dans le nœud de démarrage manuel */
workflowData?: Record<string, unknown>;
}
/**
* Déclenche manuellement l'exécution d'un workflow.
*
* @param id - Identifiant du workflow à déclencher
* @param params - Données optionnelles d'entrée
* @returns Identifiant de l'exécution créée
*/
export const runWorkflow = async (
id: string,
params?: RunWorkflowParams
): Promise<{ executionId: string }> => {
const response = await apiClient.post<{ executionId: string }>(
`/api/v1/workflows/${id}/run`,
params ?? {}
);
return response.data;
};
+107
View File
@@ -0,0 +1,107 @@
import React from 'react';
import { View, StyleSheet } from 'react-native';
import { Card, Text, IconButton, useTheme } from 'react-native-paper';
import { Execution } from '../api/executions';
import StatusBadge from './StatusBadge';
import { formatShortDate, formatDuration, formatMode } from '../utils/formatters';
interface ExecutionCardProps {
execution: Execution;
/**
* Navigue vers les logs complets de cette exécution.
* Déclenche router.push('/execution/:id') dans ExecutionsScreen.
*/
onViewLogs: (id: string) => void;
/**
* Supprime l'exécution via le hook useExecutions.
*/
onDelete: (id: string) => void;
}
/**
* Carte représentant une exécution dans la liste historique.
* Affiche : nom du workflow, badge de statut, mode de déclenchement, durée et date.
* Les actions (logs, suppression) sont déléguées aux callbacks.
*/
const ExecutionCard: React.FC<ExecutionCardProps> = ({ execution, onViewLogs, onDelete }) => {
const theme = useTheme();
return (
<Card style={[styles.card, { backgroundColor: theme.colors.surface }]}>
<Card.Content>
{/* En-tête : nom du workflow et badge de statut */}
<View style={styles.header}>
<Text
variant="titleSmall"
numberOfLines={1}
style={[styles.workflowName, { color: theme.colors.onSurface }]}
>
{execution.workflowName ?? `Workflow ${execution.workflowId}`}
</Text>
<StatusBadge status={execution.status} size="small" />
</View>
{/* Métadonnées : mode de déclenchement, durée, date */}
<View style={styles.meta}>
<Text variant="bodySmall" style={{ color: theme.colors.onSurfaceVariant }}>
{formatMode(execution.mode)}
</Text>
<Text variant="bodySmall" style={{ color: theme.colors.onSurfaceVariant }}>
{formatDuration(execution.startedAt, execution.stoppedAt)}
</Text>
<Text variant="bodySmall" style={{ color: theme.colors.onSurfaceVariant }}>
{formatShortDate(execution.startedAt)}
</Text>
</View>
</Card.Content>
{/* Actions : consulter les logs et supprimer */}
<Card.Actions style={styles.actions}>
<IconButton
icon="text-box-outline"
size={20}
iconColor={theme.colors.primary}
onPress={() => onViewLogs(execution.id)}
accessibilityLabel="Voir les logs de cette exécution"
/>
<IconButton
icon="delete-outline"
size={20}
iconColor={theme.colors.error}
onPress={() => onDelete(execution.id)}
accessibilityLabel="Supprimer cette exécution"
/>
</Card.Actions>
</Card>
);
};
const styles = StyleSheet.create({
card: {
marginHorizontal: 16,
marginVertical: 5,
elevation: 1,
},
header: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: 6,
},
workflowName: {
flex: 1,
marginRight: 8,
fontWeight: '500',
},
meta: {
flexDirection: 'row',
justifyContent: 'space-between',
},
actions: {
justifyContent: 'flex-end',
paddingRight: 4,
paddingBottom: 2,
},
});
export default ExecutionCard;
+91
View File
@@ -0,0 +1,91 @@
import React, { useEffect, useRef } from 'react';
import { View, Animated, StyleSheet, ViewStyle } from 'react-native';
interface SkeletonItemProps {
width?: number | `${number}%`;
height?: number;
borderRadius?: number;
style?: ViewStyle;
}
/**
* Bloc de squelette animé avec effet de pulsation (shimmer).
* Composant interne utilisé par SkeletonLoader pour construire les cartes placeholder.
*/
const SkeletonItem: React.FC<SkeletonItemProps> = ({
width = '100%',
height = 16,
borderRadius = 4,
style,
}) => {
const opacity = useRef(new Animated.Value(0.3)).current;
useEffect(() => {
/** Boucle d'animation : alterne entre 30% et 70% d'opacité */
const animation = Animated.loop(
Animated.sequence([
Animated.timing(opacity, {
toValue: 0.7,
duration: 800,
useNativeDriver: true,
}),
Animated.timing(opacity, {
toValue: 0.3,
duration: 800,
useNativeDriver: true,
}),
])
);
animation.start();
return () => animation.stop();
}, [opacity]);
return (
<Animated.View
style={[
styles.skeleton,
{ width: width as number, height, borderRadius, opacity },
style,
]}
/>
);
};
interface SkeletonLoaderProps {
/** Nombre de cartes placeholder à afficher pendant le chargement initial */
count?: number;
}
/**
* Affiche des cartes placeholder animées pendant le chargement des données.
* Simule la structure d'une WorkflowCard/ExecutionCard pour éviter le layout shift
* et indiquer à l'utilisateur que du contenu arrive.
*/
const SkeletonLoader: React.FC<SkeletonLoaderProps> = ({ count = 5 }) => {
return (
<>
{Array.from({ length: count }).map((_, index) => (
<View key={index} style={styles.card}>
<SkeletonItem width="60%" height={18} style={{ marginBottom: 8 }} />
<SkeletonItem width="85%" height={12} style={{ marginBottom: 6 }} />
<SkeletonItem width="40%" height={12} />
</View>
))}
</>
);
};
const styles = StyleSheet.create({
card: {
backgroundColor: '#1E1E1E',
marginHorizontal: 16,
marginVertical: 6,
padding: 16,
borderRadius: 12,
},
skeleton: {
backgroundColor: '#2C2C2C',
},
});
export default SkeletonLoader;
+69
View File
@@ -0,0 +1,69 @@
import React from 'react';
import { View, StyleSheet } from 'react-native';
import { Text } from 'react-native-paper';
import { ExecutionStatus } from '../api/executions';
import { formatStatus } from '../utils/formatters';
/**
* Couleurs de fond des badges — optimisées pour le thème sombre MD3.
* Les teintes foncées gardent le contraste sans agresser l'œil.
*/
const STATUS_BG_COLORS: Record<ExecutionStatus, string> = {
success: '#1B5E20',
error: '#7F0000',
waiting: '#E65100',
running: '#0D47A1',
canceled: '#424242',
};
/** Couleurs de texte assorties à chaque fond */
const STATUS_TEXT_COLORS: Record<ExecutionStatus, string> = {
success: '#A5D6A7',
error: '#EF9A9A',
waiting: '#FFCC80',
running: '#90CAF9',
canceled: '#BDBDBD',
};
interface StatusBadgeProps {
status: ExecutionStatus;
/**
* Taille du badge :
* - small : listes denses (ExecutionCard)
* - default : vue détail (LogsScreen)
*/
size?: 'small' | 'default';
}
/**
* Badge coloré représentant le statut d'une exécution n8n.
* Utilise les couleurs MD3 dark pour rester lisible sur les surfaces sombres.
*/
const StatusBadge: React.FC<StatusBadgeProps> = ({ status, size = 'default' }) => {
const backgroundColor = STATUS_BG_COLORS[status] ?? '#424242';
const textColor = STATUS_TEXT_COLORS[status] ?? '#BDBDBD';
const fontSize = size === 'small' ? 10 : 12;
return (
<View style={[styles.badge, { backgroundColor }]}>
<Text style={[styles.text, { color: textColor, fontSize }]}>
{formatStatus(status)}
</Text>
</View>
);
};
const styles = StyleSheet.create({
badge: {
paddingHorizontal: 8,
paddingVertical: 3,
borderRadius: 12,
alignSelf: 'flex-start',
},
text: {
fontWeight: '600',
letterSpacing: 0.3,
},
});
export default StatusBadge;
+124
View File
@@ -0,0 +1,124 @@
import React from 'react';
import { View, StyleSheet } from 'react-native';
import { Card, Text, Switch, IconButton, useTheme } from 'react-native-paper';
import { Workflow } from '../api/workflows';
import { formatRelativeDate } from '../utils/formatters';
interface WorkflowCardProps {
workflow: Workflow;
/**
* Appelé quand l'utilisateur bascule le switch actif/inactif.
* Le hook useWorkflows gère l'appel API et la mise à jour d'état.
*/
onToggle: (workflow: Workflow) => void;
/**
* Appelé quand l'utilisateur appuie sur le bouton lecture.
* Déclenche une exécution manuelle du workflow.
*/
onRun: (id: string) => void;
}
/**
* Carte représentant un workflow dans la liste WorkflowsScreen.
* Affiche : nom, statut actif (switch), date de modification, tags et bouton run.
* Le toggle et le run déclenchent des callbacks — la carte n'appelle jamais l'API directement.
*/
const WorkflowCard: React.FC<WorkflowCardProps> = ({ workflow, onToggle, onRun }) => {
const theme = useTheme();
return (
<Card style={[styles.card, { backgroundColor: theme.colors.surface }]}>
<Card.Content style={styles.content}>
{/* En-tête : nom du workflow et switch actif/inactif */}
<View style={styles.header}>
<Text
variant="titleMedium"
numberOfLines={2}
style={[styles.name, { color: theme.colors.onSurface }]}
>
{workflow.name}
</Text>
<Switch
value={workflow.active}
onValueChange={() => onToggle(workflow)}
color={theme.colors.primary}
/>
</View>
{/* Date de dernière modification */}
<Text
variant="bodySmall"
style={{ color: theme.colors.onSurfaceVariant, marginTop: 4 }}
>
Modifié {formatRelativeDate(workflow.updatedAt)}
</Text>
{/* Tags optionnels */}
{workflow.tags && workflow.tags.length > 0 && (
<View style={styles.tags}>
{workflow.tags.map((tag) => (
<View
key={tag.id}
style={[styles.tag, { backgroundColor: theme.colors.surfaceVariant }]}
>
<Text variant="labelSmall" style={{ color: theme.colors.onSurfaceVariant }}>
{tag.name}
</Text>
</View>
))}
</View>
)}
</Card.Content>
{/* Bouton de déclenchement manuel */}
<Card.Actions style={styles.actions}>
<IconButton
icon="play-circle-outline"
size={22}
iconColor={theme.colors.primary}
onPress={() => onRun(workflow.id)}
accessibilityLabel="Déclencher manuellement ce workflow"
/>
</Card.Actions>
</Card>
);
};
const styles = StyleSheet.create({
card: {
marginHorizontal: 16,
marginVertical: 6,
elevation: 2,
},
content: {
paddingBottom: 4,
},
header: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
},
name: {
flex: 1,
marginRight: 8,
fontWeight: '600',
},
tags: {
flexDirection: 'row',
flexWrap: 'wrap',
marginTop: 8,
gap: 6,
},
tag: {
paddingHorizontal: 8,
paddingVertical: 2,
borderRadius: 8,
},
actions: {
justifyContent: 'flex-end',
paddingRight: 8,
paddingBottom: 4,
},
});
export default WorkflowCard;
+137
View File
@@ -0,0 +1,137 @@
import { useState, useCallback } from 'react';
import { useAppStore } from '../store/appStore';
import {
fetchExecutions,
fetchExecutionById,
deleteExecution,
Execution,
ExecutionStatus,
FetchExecutionsParams,
} from '../api/executions';
import { usePolling } from './usePolling';
/** Options de filtrage passées au hook à l'initialisation */
interface UseExecutionsOptions {
/** Nombre max d'exécutions à charger */
limit?: number;
/** Filtre de statut initial */
statusFilter?: ExecutionStatus;
/** Restreindre aux exécutions d'un workflow spécifique */
workflowId?: string;
}
/** Interface des valeurs et actions exposées par le hook */
interface UseExecutionsResult {
executions: Execution[];
isLoading: boolean;
isRefreshing: boolean;
/** Exécution chargée en détail (avec logs de nœuds) */
selectedExecution: Execution | null;
/** Recharge la liste (pull-to-refresh) */
refresh: () => Promise<void>;
/**
* Charge les logs complets d'une exécution pour l'écran de détail.
* Peuple selectedExecution.
*
* @param id - Identifiant de l'exécution
*/
loadExecutionDetail: (id: string) => Promise<void>;
/**
* Supprime une exécution de l'historique et la retire de l'état local.
*
* @param id - Identifiant de l'exécution à supprimer
*/
removeExecution: (id: string) => Promise<void>;
/**
* Change le filtre de statut actif et relance le chargement.
*
* @param status - Statut cible ou undefined pour tout afficher
*/
setStatusFilter: (status: ExecutionStatus | undefined) => void;
}
/**
* Hook de gestion des exécutions : listing avec filtres, polling, détail et suppression.
* Sépare clairement la liste (vue Executions) du détail (vue Logs).
*/
export const useExecutions = (options: UseExecutionsOptions = {}): UseExecutionsResult => {
const { preferences, config } = useAppStore();
const [executions, setExecutions] = useState<Execution[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [isRefreshing, setIsRefreshing] = useState(false);
const [selectedExecution, setSelectedExecution] = useState<Execution | null>(null);
const [statusFilter, setStatusFilter] = useState<ExecutionStatus | undefined>(
options.statusFilter
);
/** Charge la liste avec les filtres courants */
const loadExecutions = useCallback(async (): Promise<void> => {
const params: FetchExecutionsParams = {
limit: options.limit ?? 50,
...(statusFilter !== undefined && { status: statusFilter }),
...(options.workflowId && { workflowId: options.workflowId }),
};
try {
const result = await fetchExecutions(params);
setExecutions(result.data);
} catch {
// Géré par l'intercepteur Axios
} finally {
setIsLoading(false);
}
}, [options.limit, options.workflowId, statusFilter]);
/** Polling automatique — désactivé si l'app n'est pas encore configurée */
usePolling({
callback: loadExecutions,
interval: preferences.pollingInterval,
enabled: config.isConfigured,
immediate: true,
});
const refresh = useCallback(async (): Promise<void> => {
setIsRefreshing(true);
try {
await loadExecutions();
} finally {
setIsRefreshing(false);
}
}, [loadExecutions]);
/**
* Charge les logs détaillés d'une exécution.
* Appelle GET /executions/:id qui inclut le champ `data` avec les nœuds.
*/
const loadExecutionDetail = useCallback(async (id: string): Promise<void> => {
try {
const detail = await fetchExecutionById(id);
setSelectedExecution(detail);
} catch {
// Géré par l'intercepteur Axios
}
}, []);
/**
* Supprime une exécution côté serveur et la retire optimistiquement de l'état local.
*/
const removeExecution = useCallback(async (id: string): Promise<void> => {
try {
await deleteExecution(id);
setExecutions((prev) => prev.filter((e) => e.id !== id));
} catch {
// Géré par l'intercepteur Axios
}
}, []);
return {
executions,
isLoading,
isRefreshing,
selectedExecution,
refresh,
loadExecutionDetail,
removeExecution,
setStatusFilter,
};
};
+66
View File
@@ -0,0 +1,66 @@
import { useEffect, useRef, useCallback } from 'react';
/** Options de configuration du hook de polling générique */
interface UsePollingOptions {
/** Callback exécuté à chaque intervalle (peut être async) */
callback: () => void | Promise<void>;
/** Intervalle entre chaque appel en millisecondes */
interval: number;
/** Si false, le polling est suspendu sans démonter le hook */
enabled?: boolean;
/** Si true, déclenche le callback immédiatement au montage sans attendre le premier intervalle */
immediate?: boolean;
}
/**
* Hook générique de polling configurable.
* Gère automatiquement le nettoyage de l'intervalle au démontage du composant.
* Le callback est mémorisé par ref pour éviter de redémarrer le polling à chaque render.
*
* @returns stopPolling - Fonction pour arrêter manuellement le polling
*/
export const usePolling = ({
callback,
interval,
enabled = true,
immediate = true,
}: UsePollingOptions): { stopPolling: () => void } => {
/** Ref sur le callback pour éviter de re-créer l'intervalle si la fonction change */
const callbackRef = useRef(callback);
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
/** Synchronise la ref à chaque render sans déclencher d'effet */
useEffect(() => {
callbackRef.current = callback;
}, [callback]);
/** Arrête proprement l'intervalle actif */
const stopPolling = useCallback(() => {
if (intervalRef.current) {
clearInterval(intervalRef.current);
intervalRef.current = null;
}
}, []);
useEffect(() => {
if (!enabled) {
stopPolling();
return;
}
// Appel immédiat au montage si demandé (évite l'attente du premier intervalle)
if (immediate) {
void callbackRef.current();
}
// Mise en place du polling régulier
intervalRef.current = setInterval(() => {
void callbackRef.current();
}, interval);
// Nettoyage automatique au démontage
return stopPolling;
}, [enabled, interval, immediate, stopPolling]);
return { stopPolling };
};
+114
View File
@@ -0,0 +1,114 @@
import { useState, useCallback } from 'react';
import { useAppStore } from '../store/appStore';
import {
fetchWorkflows,
activateWorkflow,
deactivateWorkflow,
runWorkflow,
Workflow,
} from '../api/workflows';
import { usePolling } from './usePolling';
/** Interface des valeurs et actions exposées par le hook */
interface UseWorkflowsResult {
workflows: Workflow[];
isLoading: boolean;
isRefreshing: boolean;
/**
* Recharge manuellement la liste des workflows.
* Utilisé pour le pull-to-refresh.
*/
refresh: () => Promise<void>;
/**
* Bascule le statut actif/inactif d'un workflow.
* Met à jour l'état local après confirmation serveur.
*
* @param workflow - Workflow à basculer
*/
toggleWorkflow: (workflow: Workflow) => Promise<void>;
/**
* Déclenche manuellement l'exécution d'un workflow.
*
* @param id - Identifiant du workflow à déclencher
*/
triggerWorkflow: (id: string) => Promise<void>;
}
/**
* Hook de gestion des workflows : chargement, polling automatique et actions.
* Délègue la gestion des erreurs à l'intercepteur Axios (errorHandler).
* Les screens ne font pas d'appels API directs — tout passe par ce hook.
*/
export const useWorkflows = (): UseWorkflowsResult => {
const { preferences, config } = useAppStore();
const [workflows, setWorkflows] = useState<Workflow[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [isRefreshing, setIsRefreshing] = useState(false);
/** Charge la liste des workflows depuis la couche API */
const loadWorkflows = useCallback(async (): Promise<void> => {
try {
const data = await fetchWorkflows();
setWorkflows(data);
} catch {
// Erreur déjà remontée via l'intercepteur Axios → toast affiché
} finally {
setIsLoading(false);
}
}, []);
/**
* Polling automatique déclenché uniquement si l'app est configurée.
* L'intervalle est lu depuis les préférences utilisateur (configurable dans Settings).
*/
usePolling({
callback: loadWorkflows,
interval: preferences.pollingInterval,
enabled: config.isConfigured,
immediate: true,
});
/** Pull-to-refresh : indicateur distinct du chargement initial */
const refresh = useCallback(async (): Promise<void> => {
setIsRefreshing(true);
try {
await loadWorkflows();
} finally {
setIsRefreshing(false);
}
}, [loadWorkflows]);
/**
* Bascule actif/inactif d'un workflow.
* Met à jour l'entrée correspondante dans l'état local après retour serveur.
*/
const toggleWorkflow = useCallback(async (workflow: Workflow): Promise<void> => {
try {
const updated = workflow.active
? await deactivateWorkflow(workflow.id)
: await activateWorkflow(workflow.id);
// Mise à jour ciblée sans recharger toute la liste
setWorkflows((prev) => prev.map((w) => (w.id === updated.id ? updated : w)));
} catch {
// Géré par l'intercepteur Axios
}
}, []);
const triggerWorkflow = useCallback(async (id: string): Promise<void> => {
try {
await runWorkflow(id);
} catch {
// Géré par l'intercepteur Axios
}
}, []);
return {
workflows,
isLoading,
isRefreshing,
refresh,
toggleWorkflow,
triggerWorkflow,
};
};
+204
View File
@@ -0,0 +1,204 @@
import React, { useMemo } from 'react';
import { ScrollView, View, StyleSheet, RefreshControl } from 'react-native';
import { Text, Card, ActivityIndicator, useTheme } from 'react-native-paper';
import { useWorkflows } from '../hooks/useWorkflows';
import { useExecutions } from '../hooks/useExecutions';
import StatusBadge from '../components/StatusBadge';
import { formatShortDate } from '../utils/formatters';
/**
* Carte de métrique simple pour le tableau de bord.
* Affiche un chiffre clé avec un label et un sous-titre coloré.
*/
interface MetricCardProps {
label: string;
value: string | number;
subtitle: string;
valueColor: string;
}
const MetricCard: React.FC<MetricCardProps> = ({ label, value, subtitle, valueColor }) => {
const theme = useTheme();
return (
<Card style={[styles.metricCard, { backgroundColor: theme.colors.surface }]}>
<Card.Content>
<Text variant="bodySmall" style={{ color: theme.colors.onSurfaceVariant }}>
{label}
</Text>
<Text variant="headlineMedium" style={{ color: valueColor, fontWeight: '700' }}>
{value}
</Text>
<Text variant="bodySmall" style={{ color: theme.colors.onSurfaceVariant }}>
{subtitle}
</Text>
</Card.Content>
</Card>
);
};
/**
* Écran principal — tableau de bord.
* Affiche les métriques clés (total workflows, taux de succès)
* et les 5 dernières exécutions avec leur statut.
* Supporte le pull-to-refresh via useWorkflows.
*/
const DashboardScreen: React.FC = () => {
const theme = useTheme();
const { workflows, isLoading: wLoading, isRefreshing, refresh } = useWorkflows();
const { executions, isLoading: eLoading } = useExecutions({ limit: 5 });
/** Métriques calculées localement depuis l'état des hooks */
const metrics = useMemo(() => {
const successCount = executions.filter((e) => e.status === 'success').length;
return {
total: workflows.length,
active: workflows.filter((w) => w.active).length,
successRate:
executions.length > 0
? Math.round((successCount / executions.length) * 100)
: 0,
};
}, [workflows, executions]);
/**
* Couleur du taux de succès : vert > 80%, orange > 50%, rouge sinon.
* Donne un indicateur visuel immédiat de la santé des workflows.
*/
const successRateColor =
metrics.successRate >= 80
? '#A5D6A7'
: metrics.successRate >= 50
? '#FFCC80'
: '#EF9A9A';
const isLoading = wLoading || eLoading;
return (
<ScrollView
style={[styles.container, { backgroundColor: theme.colors.background }]}
refreshControl={
<RefreshControl
refreshing={isRefreshing}
onRefresh={refresh}
tintColor={theme.colors.primary}
/>
}
>
<Text
variant="headlineSmall"
style={[styles.title, { color: theme.colors.onBackground }]}
>
Tableau de bord
</Text>
{isLoading ? (
<ActivityIndicator style={styles.loader} color={theme.colors.primary} />
) : (
<>
{/* Métriques rapides */}
<View style={styles.metricsRow}>
<MetricCard
label="Workflows"
value={metrics.total}
subtitle={`${metrics.active} actifs`}
valueColor={theme.colors.primary}
/>
<MetricCard
label="Taux de succès"
value={`${metrics.successRate}%`}
subtitle="5 dernières exec."
valueColor={successRateColor}
/>
</View>
{/* Section dernières exécutions */}
<Text
variant="titleMedium"
style={[styles.sectionTitle, { color: theme.colors.onBackground }]}
>
Exécutions récentes
</Text>
{executions.length === 0 ? (
<Text style={[styles.empty, { color: theme.colors.onSurfaceVariant }]}>
Aucune exécution récente.
</Text>
) : (
executions.slice(0, 5).map((exec) => (
<Card
key={exec.id}
style={[styles.execCard, { backgroundColor: theme.colors.surface }]}
>
<Card.Content style={styles.execHeader}>
<Text
variant="bodyMedium"
numberOfLines={1}
style={{ color: theme.colors.onSurface, flex: 1, marginRight: 8 }}
>
{exec.workflowName ?? `Workflow ${exec.workflowId}`}
</Text>
<StatusBadge status={exec.status} size="small" />
</Card.Content>
<Card.Content style={styles.execMeta}>
<Text variant="bodySmall" style={{ color: theme.colors.onSurfaceVariant }}>
{formatShortDate(exec.startedAt)}
</Text>
</Card.Content>
</Card>
))
)}
</>
)}
</ScrollView>
);
};
const styles = StyleSheet.create({
container: {
flex: 1,
},
title: {
margin: 16,
fontWeight: '700',
},
loader: {
marginTop: 48,
},
metricsRow: {
flexDirection: 'row',
marginHorizontal: 16,
gap: 12,
marginBottom: 8,
},
metricCard: {
flex: 1,
elevation: 2,
},
sectionTitle: {
marginHorizontal: 16,
marginTop: 20,
marginBottom: 8,
fontWeight: '600',
},
execCard: {
marginHorizontal: 16,
marginVertical: 4,
elevation: 1,
},
execHeader: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingBottom: 4,
},
execMeta: {
paddingTop: 0,
},
empty: {
textAlign: 'center',
marginTop: 24,
fontStyle: 'italic',
},
});
export default DashboardScreen;
+134
View File
@@ -0,0 +1,134 @@
import React, { useState } from 'react';
import { FlatList, StyleSheet, View } from 'react-native';
import { Text, Chip, Snackbar, useTheme } from 'react-native-paper';
import { useRouter } from 'expo-router';
import { useExecutions } from '../hooks/useExecutions';
import ExecutionCard from '../components/ExecutionCard';
import SkeletonLoader from '../components/SkeletonLoader';
import { ExecutionStatus } from '../api/executions';
/** Filtres de statut disponibles dans l'interface */
const STATUS_FILTERS: Array<{ label: string; value: ExecutionStatus | undefined }> = [
{ label: 'Tous', value: undefined },
{ label: 'Succès', value: 'success' },
{ label: 'Erreur', value: 'error' },
{ label: 'En cours', value: 'running' },
];
/**
* Écran d'historique des exécutions.
* Permet de filtrer par statut via des chips et de naviguer vers
* les logs détaillés d'une exécution via Expo Router.
*/
const ExecutionsScreen: React.FC = () => {
const theme = useTheme();
const router = useRouter();
const [activeFilter, setActiveFilter] = useState<ExecutionStatus | undefined>(undefined);
const [snackMessage, setSnackMessage] = useState('');
const {
executions,
isLoading,
isRefreshing,
refresh,
removeExecution,
setStatusFilter,
} = useExecutions({ limit: 50 });
/**
* Change le filtre actif et notifie le hook pour relancer le fetch.
*
* @param value - Nouveau statut filtré, ou undefined pour tout afficher
*/
const handleFilterChange = (value: ExecutionStatus | undefined): void => {
setActiveFilter(value);
setStatusFilter(value);
};
/**
* Supprime une exécution et affiche une confirmation.
*
* @param id - Identifiant de l'exécution à supprimer
*/
const handleDelete = async (id: string): Promise<void> => {
await removeExecution(id);
setSnackMessage('Exécution supprimée');
};
return (
<View style={[styles.container, { backgroundColor: theme.colors.background }]}>
{/* Barre de filtres de statut horizontale */}
<View style={styles.filterBar}>
{STATUS_FILTERS.map((filter) => (
<Chip
key={filter.label}
selected={activeFilter === filter.value}
onPress={() => handleFilterChange(filter.value)}
style={styles.chip}
compact
>
{filter.label}
</Chip>
))}
</View>
{isLoading ? (
<SkeletonLoader count={8} />
) : (
<FlatList
data={executions}
keyExtractor={(item) => item.id}
renderItem={({ item }) => (
<ExecutionCard
execution={item}
onViewLogs={(id) => router.push(`/execution/${id}` as `/${string}`)}
onDelete={handleDelete}
/>
)}
refreshing={isRefreshing}
onRefresh={refresh}
contentContainerStyle={styles.list}
ListEmptyComponent={
<Text style={[styles.empty, { color: theme.colors.onSurfaceVariant }]}>
Aucune exécution trouvée.
</Text>
}
/>
)}
<Snackbar
visible={!!snackMessage}
onDismiss={() => setSnackMessage('')}
duration={2500}
>
{snackMessage}
</Snackbar>
</View>
);
};
const styles = StyleSheet.create({
container: {
flex: 1,
},
filterBar: {
flexDirection: 'row',
paddingHorizontal: 16,
paddingVertical: 8,
gap: 8,
},
chip: {
height: 32,
},
list: {
paddingVertical: 4,
paddingBottom: 24,
},
empty: {
textAlign: 'center',
marginTop: 48,
fontStyle: 'italic',
},
});
export default ExecutionsScreen;
+207
View File
@@ -0,0 +1,207 @@
import React, { useEffect, useState } from 'react';
import { ScrollView, StyleSheet, View, ActivityIndicator } from 'react-native';
import { Text, Card, Divider, useTheme } from 'react-native-paper';
import { useLocalSearchParams } from 'expo-router';
import { useExecutions } from '../hooks/useExecutions';
import StatusBadge from '../components/StatusBadge';
import { formatShortDate, formatDuration, formatMode } from '../utils/formatters';
import { NodeExecutionData } from '../api/executions';
/**
* Écran de logs détaillés d'une exécution n8n.
* Reçoit l'identifiant de l'exécution via les paramètres de route Expo Router (/execution/:id).
* Charge puis affiche les données de chaque nœud exécuté, avec les erreurs éventuelles.
*/
const LogsScreen: React.FC = () => {
const theme = useTheme();
const { id } = useLocalSearchParams<{ id: string }>();
const { selectedExecution, loadExecutionDetail } = useExecutions();
const [isLoading, setIsLoading] = useState(true);
/** Charge les logs détaillés dès que l'ID de route est disponible */
useEffect(() => {
if (id) {
loadExecutionDetail(id).finally(() => setIsLoading(false));
}
}, [id]);
if (isLoading) {
return (
<View style={[styles.centered, { backgroundColor: theme.colors.background }]}>
<ActivityIndicator color={theme.colors.primary} />
</View>
);
}
if (!selectedExecution) {
return (
<View style={[styles.centered, { backgroundColor: theme.colors.background }]}>
<Text style={{ color: theme.colors.onSurfaceVariant }}>
Exécution introuvable ou données indisponibles.
</Text>
</View>
);
}
const runData = selectedExecution.data?.resultData?.runData ?? {};
const globalError = selectedExecution.data?.resultData?.error;
const nodeNames = Object.keys(runData);
return (
<ScrollView
style={[styles.container, { backgroundColor: theme.colors.background }]}
contentContainerStyle={styles.content}
>
{/* En-tête : résumé de l'exécution */}
<Card style={[styles.headerCard, { backgroundColor: theme.colors.surface }]}>
<Card.Content>
<View style={styles.headerRow}>
<Text
variant="titleMedium"
style={{ color: theme.colors.onSurface, flex: 1, marginRight: 8 }}
numberOfLines={2}
>
{selectedExecution.workflowName ?? `Workflow ${selectedExecution.workflowId}`}
</Text>
<StatusBadge status={selectedExecution.status} />
</View>
<Text
variant="bodySmall"
style={{ color: theme.colors.onSurfaceVariant, marginTop: 6 }}
>
{formatMode(selectedExecution.mode)} ·{' '}
{formatShortDate(selectedExecution.startedAt)} ·{' '}
{formatDuration(selectedExecution.startedAt, selectedExecution.stoppedAt)}
</Text>
</Card.Content>
</Card>
{/* Erreur globale de l'exécution (niveau workflow) */}
{globalError && (
<Card style={[styles.errorCard, { backgroundColor: '#2C0000' }]}>
<Card.Content>
<Text variant="labelMedium" style={{ color: '#EF9A9A', marginBottom: 4 }}>
Erreur workflow
</Text>
<Text
variant="bodySmall"
style={{ color: '#FFCDD2', fontFamily: 'monospace' }}
selectable
>
{globalError.message}
</Text>
</Card.Content>
</Card>
)}
{/* Liste des nœuds exécutés */}
<Text
variant="titleSmall"
style={[styles.sectionTitle, { color: theme.colors.onBackground }]}
>
Nœuds exécutés ({nodeNames.length})
</Text>
{nodeNames.map((nodeName, index) => {
const nodeRuns: NodeExecutionData[] = runData[nodeName] ?? [];
const firstRun = nodeRuns[0];
const hasError = !!firstRun?.error;
return (
<Card
key={nodeName}
style={[
styles.nodeCard,
{ backgroundColor: hasError ? '#1C0000' : theme.colors.surface },
]}
>
<Card.Content>
{/* Nom du nœud et durée d'exécution */}
<View style={styles.nodeHeader}>
<Text
variant="bodyMedium"
style={{ color: theme.colors.onSurface, fontWeight: '600', flex: 1 }}
>
{index + 1}. {nodeName}
</Text>
{firstRun && (
<Text variant="bodySmall" style={{ color: theme.colors.onSurfaceVariant }}>
{firstRun.executionTime}ms
</Text>
)}
</View>
{/* Erreur spécifique à ce nœud */}
{firstRun?.error && (
<>
<Divider style={{ marginVertical: 6 }} />
<Text
variant="bodySmall"
style={{ color: '#EF9A9A', fontFamily: 'monospace' }}
selectable
>
{firstRun.error.message}
</Text>
</>
)}
</Card.Content>
</Card>
);
})}
{nodeNames.length === 0 && (
<Text style={[styles.empty, { color: theme.colors.onSurfaceVariant }]}>
Aucune donnée de nœud disponible pour cette exécution.
</Text>
)}
</ScrollView>
);
};
const styles = StyleSheet.create({
container: {
flex: 1,
},
content: {
padding: 16,
paddingBottom: 40,
},
centered: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
},
headerCard: {
marginBottom: 12,
elevation: 2,
},
headerRow: {
flexDirection: 'row',
alignItems: 'flex-start',
justifyContent: 'space-between',
},
errorCard: {
marginBottom: 12,
elevation: 2,
},
sectionTitle: {
marginBottom: 8,
fontWeight: '600',
},
nodeCard: {
marginBottom: 8,
elevation: 1,
},
nodeHeader: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
},
empty: {
textAlign: 'center',
marginTop: 24,
fontStyle: 'italic',
},
});
export default LogsScreen;
+329
View File
@@ -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;
+95
View File
@@ -0,0 +1,95 @@
import React, { useState } from 'react';
import { FlatList, StyleSheet, View } from 'react-native';
import { Text, Snackbar, useTheme } from 'react-native-paper';
import { useWorkflows } from '../hooks/useWorkflows';
import WorkflowCard from '../components/WorkflowCard';
import SkeletonLoader from '../components/SkeletonLoader';
import { Workflow } from '../api/workflows';
/**
* Écran de liste de tous les workflows de l'instance n8n.
* Supporte le pull-to-refresh et affiche un squelette au premier chargement.
* Les actions (toggle, run) sont déléguées à useWorkflows — aucun appel API direct ici.
*/
const WorkflowsScreen: React.FC = () => {
const theme = useTheme();
const [snackMessage, setSnackMessage] = useState('');
const { workflows, isLoading, isRefreshing, refresh, toggleWorkflow, triggerWorkflow } =
useWorkflows();
/**
* Déclenche l'exécution d'un workflow et affiche une confirmation à l'utilisateur.
*
* @param id - Identifiant du workflow à lancer
*/
const handleRun = async (id: string): Promise<void> => {
await triggerWorkflow(id);
setSnackMessage('Workflow déclenché avec succès');
};
/**
* Bascule le statut actif/inactif avec retour visuel.
* L'état local est mis à jour par le hook après confirmation serveur.
*
* @param workflow - Workflow à basculer
*/
const handleToggle = async (workflow: Workflow): Promise<void> => {
await toggleWorkflow(workflow);
setSnackMessage(workflow.active ? 'Workflow désactivé' : 'Workflow activé');
};
return (
<View style={[styles.container, { backgroundColor: theme.colors.background }]}>
{isLoading ? (
<SkeletonLoader count={6} />
) : (
<FlatList
data={workflows}
keyExtractor={(item) => item.id}
renderItem={({ item }) => (
<WorkflowCard
workflow={item}
onToggle={handleToggle}
onRun={handleRun}
/>
)}
refreshing={isRefreshing}
onRefresh={refresh}
contentContainerStyle={styles.list}
ListEmptyComponent={
<Text style={[styles.empty, { color: theme.colors.onSurfaceVariant }]}>
Aucun workflow trouvé. Vérifiez la connexion à l&apos;instance n8n.
</Text>
}
/>
)}
{/* Confirmation d'action non destructive */}
<Snackbar
visible={!!snackMessage}
onDismiss={() => setSnackMessage('')}
duration={2500}
>
{snackMessage}
</Snackbar>
</View>
);
};
const styles = StyleSheet.create({
container: {
flex: 1,
},
list: {
paddingVertical: 8,
paddingBottom: 80,
},
empty: {
textAlign: 'center',
marginTop: 48,
marginHorizontal: 32,
fontStyle: 'italic',
},
});
export default WorkflowsScreen;
+105
View File
@@ -0,0 +1,105 @@
import { create } from 'zustand';
import * as SecureStore from 'expo-secure-store';
import { SECURE_STORE_KEYS } from '../api/client';
/** Configuration de connexion à l'instance n8n auto-hébergée */
interface AppConfig {
/** URL de base de l'instance (ex: https://n8n.example.com) */
baseUrl: string;
/** Vrai si une configuration complète a déjà été sauvegardée dans le secure store */
isConfigured: boolean;
}
/** Préférences utilisateur persistées localement */
interface AppPreferences {
/** Intervalle de polling automatique en millisecondes */
pollingInterval: number;
/** Active l'authentification biométrique au démarrage de l'app */
biometricEnabled: boolean;
}
/** Interface complète de l'état global Zustand */
interface AppState {
config: AppConfig;
preferences: AppPreferences;
/** Vrai si l'authentification biométrique a été validée pour la session courante */
isAuthenticated: boolean;
isLoading: boolean;
/**
* Sauvegarde baseUrl, apiKey et appToken dans le secure store Android Keystore.
* N'appeler qu'avec des valeurs non vides et validées (HTTPS obligatoire pour baseUrl).
*
* @param baseUrl - URL de l'instance n8n en HTTPS
* @param apiKey - Clé API n8n (scope minimal recommandé)
* @param appToken - Token personnalisé HAProxy (X-App-Token)
*/
setConfig: (baseUrl: string, apiKey: string, appToken: string) => Promise<void>;
/**
* Charge la configuration au démarrage depuis le secure store.
* Appelé une seule fois par le root layout (_layout.tsx).
*/
loadConfig: () => Promise<void>;
/**
* Met à jour les préférences utilisateur de manière partielle.
*
* @param prefs - Sous-ensemble des préférences à modifier
*/
setPreferences: (prefs: Partial<AppPreferences>) => void;
/**
* Marque la session comme authentifiée (biométrie validée).
*
* @param auth - true si la biométrie a été acceptée
*/
setAuthenticated: (auth: boolean) => void;
}
export const useAppStore = create<AppState>((set) => ({
config: {
baseUrl: '',
isConfigured: false,
},
preferences: {
pollingInterval: 30_000, // 30 secondes — bon compromis entre fraîcheur et batterie
biometricEnabled: false,
},
isAuthenticated: false,
isLoading: false,
setConfig: async (baseUrl, apiKey, appToken) => {
// Écriture parallèle dans le secure store pour minimiser la latence
await Promise.all([
SecureStore.setItemAsync(SECURE_STORE_KEYS.BASE_URL, baseUrl),
SecureStore.setItemAsync(SECURE_STORE_KEYS.API_KEY, apiKey),
SecureStore.setItemAsync(SECURE_STORE_KEYS.APP_TOKEN, appToken),
]);
// On ne stocke que l'URL dans l'état React ; jamais la clé API ni le token
set({ config: { baseUrl, isConfigured: true } });
},
loadConfig: async () => {
set({ isLoading: true });
try {
// Seule l'URL est lue dans le store visible — les secrets restent dans le secure store
const baseUrl = await SecureStore.getItemAsync(SECURE_STORE_KEYS.BASE_URL);
set({
config: {
baseUrl: baseUrl ?? '',
isConfigured: !!baseUrl,
},
});
} finally {
set({ isLoading: false });
}
},
setPreferences: (prefs) =>
set((state) => ({
preferences: { ...state.preferences, ...prefs },
})),
setAuthenticated: (auth) => set({ isAuthenticated: auth }),
}));
+73
View File
@@ -0,0 +1,73 @@
import { AxiosError } from 'axios';
/** Signature du callback de toast enregistré depuis le root layout */
type ToastCallback = (message: string, type: 'error' | 'warning' | 'info') => void;
/** Référence au callback toast, initialisée au montage du root layout */
let toastCallback: ToastCallback | null = null;
/**
* Enregistre le callback de toast depuis le root layout (_layout.tsx).
* Doit être appelé une seule fois au démarrage de l'application.
*
* @param callback - Fonction qui affiche un toast dans l'UI
*/
export const registerToastCallback = (callback: ToastCallback): void => {
toastCallback = callback;
};
/**
* Affiche un toast via le callback enregistré.
* Ne fait rien si le callback n'a pas encore été enregistré.
*/
const showToast = (message: string, type: 'error' | 'warning' | 'info' = 'error'): void => {
if (toastCallback) {
toastCallback(message, type);
}
};
/**
* Extrait un message d'erreur lisible depuis une erreur Axios ou générique.
* Ne logue jamais les détails de la requête (qui pourraient contenir des headers secrets).
*
* @param error - Erreur Axios ou Error générique
* @returns Message d'erreur lisible en français
*/
const extractMessage = (error: unknown): string => {
if (error instanceof AxiosError) {
if (error.response) {
// Erreur HTTP avec réponse du serveur (4xx, 5xx)
const data = error.response.data as { message?: string; error?: string } | null;
return data?.message ?? data?.error ?? `Erreur ${error.response.status}`;
}
if (error.request) {
// Requête émise mais pas de réponse : timeout ou serveur inaccessible
return 'Serveur inaccessible. Vérifiez votre connexion ou l\'URL n8n.';
}
return error.message;
}
if (error instanceof Error) {
return error.message;
}
return 'Une erreur inattendue est survenue.';
};
/**
* Point d'entrée unifié pour la gestion des erreurs applicatives.
* Affiche un toast et émet un warning console en développement UNIQUEMENT.
* Jamais de log en production, jamais de log des valeurs de headers ou tokens.
*
* @param error - Erreur à traiter
* @param context - Contexte optionnel (nom de la fonction appelante)
*/
export const handleError = (error: unknown, context?: string): void => {
const message = extractMessage(error);
const fullMessage = context ? `[${context}] ${message}` : message;
if (__DEV__) {
// En développement : log du message uniquement, jamais des headers/tokens
console.warn(`[ErrorHandler] ${fullMessage}`);
}
showToast(fullMessage, 'error');
};
+87
View File
@@ -0,0 +1,87 @@
import { formatDistanceToNow, format, differenceInSeconds } from 'date-fns';
import { fr } from 'date-fns/locale';
/**
* Formate une date ISO en temps relatif lisible en français.
* Ex : "il y a 3 minutes", "dans 2 heures"
*
* @param dateString - Date au format ISO 8601
* @returns Chaîne relative en français, ou "date inconnue" si parsing impossible
*/
export const formatRelativeDate = (dateString: string): string => {
try {
return formatDistanceToNow(new Date(dateString), { addSuffix: true, locale: fr });
} catch {
return 'date inconnue';
}
};
/**
* Formate une date ISO en format court DD/MM/YYYY HH:mm.
*
* @param dateString - Date au format ISO 8601
* @returns Chaîne formatée, ou "date inconnue" si parsing impossible
*/
export const formatShortDate = (dateString: string): string => {
try {
return format(new Date(dateString), 'dd/MM/yyyy HH:mm', { locale: fr });
} catch {
return 'date inconnue';
}
};
/**
* Calcule et formate la durée entre deux dates.
* Si stoppedAt est absent, calcule jusqu'à maintenant (exécution en cours).
*
* @param startedAt - Date de début ISO 8601
* @param stoppedAt - Date de fin ISO 8601 (optionnelle)
* @returns Durée formatée ex : "2m 34s", "45s"
*/
export const formatDuration = (startedAt: string, stoppedAt?: string): string => {
try {
const start = new Date(startedAt);
const end = stoppedAt ? new Date(stoppedAt) : new Date();
const seconds = differenceInSeconds(end, start);
if (seconds < 60) return `${seconds}s`;
const minutes = Math.floor(seconds / 60);
const remainingSeconds = seconds % 60;
return remainingSeconds > 0 ? `${minutes}m ${remainingSeconds}s` : `${minutes}m`;
} catch {
return 'durée inconnue';
}
};
/**
* Traduit le statut d'une exécution n8n en libellé français.
*
* @param status - Statut brut de l'API n8n
* @returns Libellé français correspondant
*/
export const formatStatus = (status: string): string => {
const statusMap: Record<string, string> = {
success: 'Succès',
error: 'Erreur',
waiting: 'En attente',
running: 'En cours',
canceled: 'Annulé',
};
return statusMap[status] ?? status;
};
/**
* Traduit le mode de déclenchement d'une exécution n8n.
*
* @param mode - Mode brut retourné par l'API
* @returns Libellé français correspondant
*/
export const formatMode = (mode: string): string => {
const modeMap: Record<string, string> = {
manual: 'Manuel',
trigger: 'Déclencheur',
webhook: 'Webhook',
internal: 'Interne',
retry: 'Réessai',
};
return modeMap[mode] ?? mode;
};
+15
View File
@@ -0,0 +1,15 @@
{
"extends": "expo/tsconfig.base",
"compilerOptions": {
"strict": true,
"paths": {
"@/*": ["./src/*"]
}
},
"include": [
"**/*.ts",
"**/*.tsx",
".expo/types/**/*.d.ts",
"expo-env.d.ts"
]
}