Compare commits

..

1 Commits

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

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-21 18:34:56 +02:00
7 changed files with 81 additions and 22 deletions
+52 -10
View File
@@ -1,9 +1,10 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { View } from 'react-native'; import { View } from 'react-native';
import { Stack, useRouter, useSegments } from 'expo-router'; import { Stack, useRouter, useSegments } from 'expo-router';
import { PaperProvider, MD3DarkTheme, ActivityIndicator, Snackbar } from 'react-native-paper'; import { PaperProvider, MD3DarkTheme, ActivityIndicator, Snackbar, Button, Text } from 'react-native-paper';
import { SafeAreaProvider } from 'react-native-safe-area-context'; import { SafeAreaProvider } from 'react-native-safe-area-context';
import { GestureHandlerRootView } from 'react-native-gesture-handler'; import { GestureHandlerRootView } from 'react-native-gesture-handler';
import * as LocalAuthentication from 'expo-local-authentication';
import { useAppStore } from '../src/store/appStore'; import { useAppStore } from '../src/store/appStore';
import { registerToastCallback } from '../src/utils/errorHandler'; import { registerToastCallback } from '../src/utils/errorHandler';
@@ -36,7 +37,7 @@ const APP_THEME = {
export default function RootLayout() { export default function RootLayout() {
const router = useRouter(); const router = useRouter();
const segments = useSegments(); const segments = useSegments();
const { config, loadConfig } = useAppStore(); const { config, preferences, isAuthenticated, loadConfig, setAuthenticated } = useAppStore();
const [isReady, setIsReady] = useState(false); const [isReady, setIsReady] = useState(false);
const [toastMessage, setToastMessage] = useState(''); const [toastMessage, setToastMessage] = useState('');
const [toastVisible, setToastVisible] = useState(false); const [toastVisible, setToastVisible] = useState(false);
@@ -52,25 +53,44 @@ export default function RootLayout() {
}); });
}, []); }, []);
/** Charge la configuration depuis le secure store puis marque l'app comme prête */ const runBiometricAuth = async (): Promise<boolean> => {
const result = await LocalAuthentication.authenticateAsync({
promptMessage: 'Authentifiez-vous pour accéder à n8n Pilot',
cancelLabel: 'Annuler',
});
return result.success;
};
useEffect(() => { useEffect(() => {
loadConfig().finally(() => setIsReady(true)); const init = async () => {
await loadConfig();
const { preferences: prefs } = useAppStore.getState();
if (prefs.biometricEnabled) {
const success = await runBiometricAuth();
setAuthenticated(success);
} else {
setAuthenticated(true);
}
setIsReady(true);
};
init();
}, []); }, []);
/** const handleUnlock = async (): Promise<void> => {
* Gère la redirection initiale selon l'état de configuration. const success = await runBiometricAuth();
* - Pas configuré et hors /setup → redirige vers /setup if (success) setAuthenticated(true);
* - Configuré et dans /setup → redirige vers les tabs };
*/
useEffect(() => { useEffect(() => {
if (!isReady) return; if (!isReady) return;
if (preferences.biometricEnabled && !isAuthenticated) return;
const inSetup = segments[0] === 'setup'; const inSetup = segments[0] === 'setup';
if (!config.isConfigured && !inSetup) { if (!config.isConfigured && !inSetup) {
router.replace('/setup'); router.replace('/setup');
} else if (config.isConfigured && inSetup) { } else if (config.isConfigured && inSetup) {
router.replace('/(tabs)'); router.replace('/(tabs)');
} }
}, [isReady, config.isConfigured, segments]); }, [isReady, config.isConfigured, isAuthenticated, preferences.biometricEnabled, segments]);
if (!isReady) { if (!isReady) {
return ( return (
@@ -80,6 +100,28 @@ export default function RootLayout() {
); );
} }
if (preferences.biometricEnabled && !isAuthenticated) {
return (
<GestureHandlerRootView style={{ flex: 1 }}>
<SafeAreaProvider>
<PaperProvider theme={APP_THEME}>
<View style={{ flex: 1, alignItems: 'center', justifyContent: 'center', backgroundColor: '#121212', paddingHorizontal: 32 }}>
<Text variant="headlineMedium" style={{ color: '#FF6D3E', marginBottom: 12 }}>
n8n Pilot
</Text>
<Text variant="bodyMedium" style={{ color: '#FFFFFF', marginBottom: 32, textAlign: 'center' }}>
Authentification biométrique requise
</Text>
<Button mode="contained" onPress={handleUnlock} icon="fingerprint">
Déverrouiller
</Button>
</View>
</PaperProvider>
</SafeAreaProvider>
</GestureHandlerRootView>
);
}
return ( return (
<GestureHandlerRootView style={{ flex: 1 }}> <GestureHandlerRootView style={{ flex: 1 }}>
<SafeAreaProvider> <SafeAreaProvider>
+1
View File
@@ -15,6 +15,7 @@ export const SECURE_STORE_KEYS = {
BASE_URL: 'n8n_base_url', BASE_URL: 'n8n_base_url',
API_KEY: 'n8n_api_key', API_KEY: 'n8n_api_key',
APP_TOKEN: 'n8n_app_token', APP_TOKEN: 'n8n_app_token',
BIOMETRIC_ENABLED: 'n8n_biometric_enabled',
} as const; } as const;
/** /**
+3 -1
View File
@@ -93,7 +93,9 @@ export const fetchExecutions = async (
* @returns Exécution complète avec données de nœuds * @returns Exécution complète avec données de nœuds
*/ */
export const fetchExecutionById = async (id: string): Promise<Execution> => { export const fetchExecutionById = async (id: string): Promise<Execution> => {
const response = await apiClient.get<Execution>(`/api/v1/executions/${id}`); const response = await apiClient.get<Execution>(`/api/v1/executions/${id}`, {
params: { includeData: true },
});
return response.data; return response.data;
}; };
-4
View File
@@ -96,11 +96,7 @@ export const useWorkflows = (): UseWorkflowsResult => {
}, []); }, []);
const triggerWorkflow = useCallback(async (id: string): Promise<void> => { const triggerWorkflow = useCallback(async (id: string): Promise<void> => {
try {
await runWorkflow(id); await runWorkflow(id);
} catch {
// Géré par l'intercepteur Axios
}
}, []); }, []);
return { return {
+1
View File
@@ -122,6 +122,7 @@ const SettingsScreen: React.FC = () => {
SecureStore.deleteItemAsync(SECURE_STORE_KEYS.BASE_URL), SecureStore.deleteItemAsync(SECURE_STORE_KEYS.BASE_URL),
SecureStore.deleteItemAsync(SECURE_STORE_KEYS.API_KEY), SecureStore.deleteItemAsync(SECURE_STORE_KEYS.API_KEY),
SecureStore.deleteItemAsync(SECURE_STORE_KEYS.APP_TOKEN), SecureStore.deleteItemAsync(SECURE_STORE_KEYS.APP_TOKEN),
SecureStore.deleteItemAsync(SECURE_STORE_KEYS.BIOMETRIC_ENABLED),
]); ]);
setBaseUrl(''); setBaseUrl('');
setApiKey(''); setApiKey('');
+5 -1
View File
@@ -23,8 +23,12 @@ const WorkflowsScreen: React.FC = () => {
* @param id - Identifiant du workflow à lancer * @param id - Identifiant du workflow à lancer
*/ */
const handleRun = async (id: string): Promise<void> => { const handleRun = async (id: string): Promise<void> => {
try {
await triggerWorkflow(id); await triggerWorkflow(id);
setSnackMessage('Workflow déclenché avec succès'); setSnackMessage('Exécution déclenchée');
} catch {
// L'intercepteur Axios a déjà affiché le toast d'erreur
}
}; };
/** /**
+17 -4
View File
@@ -83,23 +83,36 @@ export const useAppStore = create<AppState>((set) => ({
loadConfig: async () => { loadConfig: async () => {
set({ isLoading: true }); set({ isLoading: true });
try { try {
// Seule l'URL est lue dans le store visible — les secrets restent dans le secure store const [baseUrl, biometricRaw] = await Promise.all([
const baseUrl = await SecureStore.getItemAsync(SECURE_STORE_KEYS.BASE_URL); SecureStore.getItemAsync(SECURE_STORE_KEYS.BASE_URL),
SecureStore.getItemAsync(SECURE_STORE_KEYS.BIOMETRIC_ENABLED),
]);
set({ set({
config: { config: {
baseUrl: baseUrl ?? '', baseUrl: baseUrl ?? '',
isConfigured: !!baseUrl, isConfigured: !!baseUrl,
}, },
preferences: {
pollingInterval: 30_000,
biometricEnabled: biometricRaw === 'true',
},
}); });
} finally { } finally {
set({ isLoading: false }); set({ isLoading: false });
} }
}, },
setPreferences: (prefs) => setPreferences: (prefs) => {
if ('biometricEnabled' in prefs) {
SecureStore.setItemAsync(
SECURE_STORE_KEYS.BIOMETRIC_ENABLED,
prefs.biometricEnabled ? 'true' : 'false'
).catch(() => {});
}
set((state) => ({ set((state) => ({
preferences: { ...state.preferences, ...prefs }, preferences: { ...state.preferences, ...prefs },
})), }));
},
setAuthenticated: (auth) => set({ isAuthenticated: auth }), setAuthenticated: (auth) => set({ isAuthenticated: auth }),
})); }));