From 92e67d0769a1054d674c17b8b9714a3ca2bc5fb6 Mon Sep 17 00:00:00 2001 From: billisdead Date: Wed, 20 May 2026 17:31:55 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20application=20n8n=20Pilot=20compl=C3=A8?= =?UTF-8?q?te=20(Expo=20managed=20workflow)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .env.example | 14 + .gitignore | 7 +- App.js | 20 - app.json | 28 +- app/(tabs)/_layout.tsx | 81 ++ app/(tabs)/executions.tsx | 3 + app/(tabs)/index.tsx | 3 + app/(tabs)/settings.tsx | 3 + app/(tabs)/workflows.tsx | 3 + app/_layout.tsx | 123 +++ app/execution/[id].tsx | 3 + app/setup.tsx | 49 + babel.config.js | 7 + docs/BACKUP.md | 89 ++ docs/HAPROXY.md | 195 ++++ docs/INSTALL.md | 136 +++ docs/SECURITY.md | 147 +++ docs/UPDATE.md | 78 ++ eas.json | 29 + index.js | 8 - package-lock.json | 1646 ++++++++++++++++++++++++++++- package.json | 24 +- src/api/client.ts | 77 ++ src/api/executions.ts | 107 ++ src/api/workflows.ts | 112 ++ src/components/ExecutionCard.tsx | 107 ++ src/components/SkeletonLoader.tsx | 91 ++ src/components/StatusBadge.tsx | 69 ++ src/components/WorkflowCard.tsx | 124 +++ src/hooks/useExecutions.ts | 137 +++ src/hooks/usePolling.ts | 66 ++ src/hooks/useWorkflows.ts | 114 ++ src/screens/DashboardScreen.tsx | 204 ++++ src/screens/ExecutionsScreen.tsx | 134 +++ src/screens/LogsScreen.tsx | 207 ++++ src/screens/SettingsScreen.tsx | 329 ++++++ src/screens/WorkflowsScreen.tsx | 95 ++ src/store/appStore.ts | 105 ++ src/utils/errorHandler.ts | 73 ++ src/utils/formatters.ts | 87 ++ tsconfig.json | 15 + 41 files changed, 4891 insertions(+), 58 deletions(-) create mode 100644 .env.example delete mode 100644 App.js create mode 100644 app/(tabs)/_layout.tsx create mode 100644 app/(tabs)/executions.tsx create mode 100644 app/(tabs)/index.tsx create mode 100644 app/(tabs)/settings.tsx create mode 100644 app/(tabs)/workflows.tsx create mode 100644 app/_layout.tsx create mode 100644 app/execution/[id].tsx create mode 100644 app/setup.tsx create mode 100644 babel.config.js create mode 100644 docs/BACKUP.md create mode 100644 docs/HAPROXY.md create mode 100644 docs/INSTALL.md create mode 100644 docs/SECURITY.md create mode 100644 docs/UPDATE.md create mode 100644 eas.json delete mode 100644 index.js create mode 100644 src/api/client.ts create mode 100644 src/api/executions.ts create mode 100644 src/api/workflows.ts create mode 100644 src/components/ExecutionCard.tsx create mode 100644 src/components/SkeletonLoader.tsx create mode 100644 src/components/StatusBadge.tsx create mode 100644 src/components/WorkflowCard.tsx create mode 100644 src/hooks/useExecutions.ts create mode 100644 src/hooks/usePolling.ts create mode 100644 src/hooks/useWorkflows.ts create mode 100644 src/screens/DashboardScreen.tsx create mode 100644 src/screens/ExecutionsScreen.tsx create mode 100644 src/screens/LogsScreen.tsx create mode 100644 src/screens/SettingsScreen.tsx create mode 100644 src/screens/WorkflowsScreen.tsx create mode 100644 src/store/appStore.ts create mode 100644 src/utils/errorHandler.ts create mode 100644 src/utils/formatters.ts create mode 100644 tsconfig.json diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..829d900 --- /dev/null +++ b/.env.example @@ -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 diff --git a/.gitignore b/.gitignore index d914c32..6dfeeb0 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/App.js b/App.js deleted file mode 100644 index 09f879b..0000000 --- a/App.js +++ /dev/null @@ -1,20 +0,0 @@ -import { StatusBar } from 'expo-status-bar'; -import { StyleSheet, Text, View } from 'react-native'; - -export default function App() { - return ( - - Open up App.js to start working on your app! - - - ); -} - -const styles = StyleSheet.create({ - container: { - flex: 1, - backgroundColor: '#fff', - alignItems: 'center', - justifyContent: 'center', - }, -}); diff --git a/app.json b/app.json index ccf0246..93da178 100644 --- a/app.json +++ b/app.json @@ -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" + } + ] + ] } } diff --git a/app/(tabs)/_layout.tsx b/app/(tabs)/_layout.tsx new file mode 100644 index 0000000..40ec781 --- /dev/null +++ b/app/(tabs)/_layout.tsx @@ -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['name']; + color: string; + size: number; +} + +/** Composant d'icône de tab — évite la répétition du rendu inline */ +const TabIcon: React.FC = ({ name, color, size }) => ( + +); + +/** + * 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 ( + + ( + + ), + }} + /> + ( + + ), + }} + /> + ( + + ), + }} + /> + ( + + ), + }} + /> + + ); +} diff --git a/app/(tabs)/executions.tsx b/app/(tabs)/executions.tsx new file mode 100644 index 0000000..ecafab5 --- /dev/null +++ b/app/(tabs)/executions.tsx @@ -0,0 +1,3 @@ +import ExecutionsScreen from '../../src/screens/ExecutionsScreen'; + +export default ExecutionsScreen; diff --git a/app/(tabs)/index.tsx b/app/(tabs)/index.tsx new file mode 100644 index 0000000..cfac0af --- /dev/null +++ b/app/(tabs)/index.tsx @@ -0,0 +1,3 @@ +import DashboardScreen from '../../src/screens/DashboardScreen'; + +export default DashboardScreen; diff --git a/app/(tabs)/settings.tsx b/app/(tabs)/settings.tsx new file mode 100644 index 0000000..12bc1b6 --- /dev/null +++ b/app/(tabs)/settings.tsx @@ -0,0 +1,3 @@ +import SettingsScreen from '../../src/screens/SettingsScreen'; + +export default SettingsScreen; diff --git a/app/(tabs)/workflows.tsx b/app/(tabs)/workflows.tsx new file mode 100644 index 0000000..dfac2db --- /dev/null +++ b/app/(tabs)/workflows.tsx @@ -0,0 +1,3 @@ +import WorkflowsScreen from '../../src/screens/WorkflowsScreen'; + +export default WorkflowsScreen; diff --git a/app/_layout.tsx b/app/_layout.tsx new file mode 100644 index 0000000..de72729 --- /dev/null +++ b/app/_layout.tsx @@ -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 ( + + + + ); + } + + return ( + + + + + + + + + + {/* Toast global pour toutes les erreurs remontées par l'errorHandler */} + setToastVisible(false)} + duration={4000} + style={{ backgroundColor: '#4A0000' }} + > + {toastMessage} + + + + + ); +} diff --git a/app/execution/[id].tsx b/app/execution/[id].tsx new file mode 100644 index 0000000..ed6f1e4 --- /dev/null +++ b/app/execution/[id].tsx @@ -0,0 +1,3 @@ +import LogsScreen from '../../src/screens/LogsScreen'; + +export default LogsScreen; diff --git a/app/setup.tsx b/app/setup.tsx new file mode 100644 index 0000000..32b6f90 --- /dev/null +++ b/app/setup.tsx @@ -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 ( + + + + n8n Pilot + + + Configurez votre instance n8n pour commencer + + + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + }, + header: { + paddingHorizontal: 24, + paddingTop: 24, + paddingBottom: 8, + alignItems: 'center', + gap: 4, + }, + title: { + fontWeight: '700', + }, +}); diff --git a/babel.config.js b/babel.config.js new file mode 100644 index 0000000..d872de3 --- /dev/null +++ b/babel.config.js @@ -0,0 +1,7 @@ +module.exports = function (api) { + api.cache(true); + return { + presets: ['babel-preset-expo'], + plugins: ['react-native-reanimated/plugin'], + }; +}; diff --git a/docs/BACKUP.md b/docs/BACKUP.md new file mode 100644 index 0000000..9761213 --- /dev/null +++ b/docs/BACKUP.md @@ -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 +``` diff --git a/docs/HAPROXY.md b/docs/HAPROXY.md new file mode 100644 index 0000000..d8bfab7 --- /dev/null +++ b/docs/HAPROXY.md @@ -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 +``` diff --git a/docs/INSTALL.md b/docs/INSTALL.md new file mode 100644 index 0000000..93f9f32 --- /dev/null +++ b/docs/INSTALL.md @@ -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 10–15 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. diff --git a/docs/SECURITY.md b/docs/SECURITY.md new file mode 100644 index 0000000..cc10ff2 --- /dev/null +++ b/docs/SECURITY.md @@ -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 diff --git a/docs/UPDATE.md b/docs/UPDATE.md new file mode 100644 index 0000000..5e07689 --- /dev/null +++ b/docs/UPDATE.md @@ -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 +``` diff --git a/eas.json b/eas.json new file mode 100644 index 0000000..256dff7 --- /dev/null +++ b/eas.json @@ -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": {} + } +} diff --git a/index.js b/index.js deleted file mode 100644 index 1d6e981..0000000 --- a/index.js +++ /dev/null @@ -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); diff --git a/package-lock.json b/package-lock.json index 50a7e2b..3384888 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,17 +1,33 @@ { - "name": "n8n-app", + "name": "n8n", "version": "1.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "n8n-app", + "name": "n8n", "version": "1.0.0", "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" + }, + "devDependencies": { + "@types/react": "^19.2.15", + "@types/react-native": "^0.72.8", + "typescript": "^6.0.3" } }, "node_modules/@0no-co/graphql.web": { @@ -1515,6 +1531,40 @@ "node": ">=6.9.0" } }, + "node_modules/@callstack/react-theme-provider": { + "version": "3.0.9", + "resolved": "https://registry.npmjs.org/@callstack/react-theme-provider/-/react-theme-provider-3.0.9.tgz", + "integrity": "sha512-tTQ0uDSCL0ypeMa8T/E9wAZRGKWj8kXP7+6RYgPTfOPs9N07C9xM8P02GJ3feETap4Ux5S69D9nteq9mEj86NA==", + "license": "MIT", + "dependencies": { + "deepmerge": "^3.2.0", + "hoist-non-react-statics": "^3.3.0" + }, + "peerDependencies": { + "react": ">=16.3.0" + } + }, + "node_modules/@callstack/react-theme-provider/node_modules/deepmerge": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-3.3.0.tgz", + "integrity": "sha512-GRQOafGHwMHpjPx9iCvTgpu9NojZ49q794EEL94JVEw6VaeA8XTUyBKvAkOOjBX9oJNiV6G3P+T+tihFjo2TqA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/@egjs/hammerjs": { + "version": "2.0.17", + "resolved": "https://registry.npmjs.org/@egjs/hammerjs/-/hammerjs-2.0.17.tgz", + "integrity": "sha512-XQsZgjm2EcVUiZQf11UBJQfmZeEmOW8DpI1gsFeln6w0ae0ii4dMQEQ0kjl6DspdWX1aGY1/loyXnP0JS06e/A==", + "license": "MIT", + "dependencies": { + "@types/hammerjs": "^2.0.36" + }, + "engines": { + "node": ">=0.8.0" + } + }, "node_modules/@expo/code-signing-certificates": { "version": "0.0.6", "resolved": "https://registry.npmjs.org/@expo/code-signing-certificates/-/code-signing-certificates-0.0.6.tgz", @@ -2700,6 +2750,463 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@radix-ui/primitive": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz", + "integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==", + "license": "MIT" + }, + "node_modules/@radix-ui/react-collection": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.7.tgz", + "integrity": "sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-collection/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-compose-refs": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz", + "integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-context": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.15.tgz", + "integrity": "sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-focus-guards": "1.1.3", + "@radix-ui/react-focus-scope": "1.1.7", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.6.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-direction": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.1.tgz", + "integrity": "sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dismissable-layer": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.11.tgz", + "integrity": "sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-escape-keydown": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-guards": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.3.tgz", + "integrity": "sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-scope": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.7.tgz", + "integrity": "sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-id": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.1.tgz", + "integrity": "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-portal": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.9.tgz", + "integrity": "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-presence": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.5.tgz", + "integrity": "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-primitive/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-roving-focus": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.11.tgz", + "integrity": "sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-slot": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.0.tgz", + "integrity": "sha512-ujc+V6r0HNDviYqIK3rW4ffgYiZ8g5DEHrGJVk4x7kTlLXRDILnKX9vAUYeIsLOoDpDJ0ujpqMkjH4w2ofuo6w==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-callback-ref": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz", + "integrity": "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-controllable-state": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.2.tgz", + "integrity": "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-effect-event": "0.0.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-effect-event": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.2.tgz", + "integrity": "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-escape-keydown": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.1.tgz", + "integrity": "sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-callback-ref": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-layout-effect": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz", + "integrity": "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@react-native/assets-registry": { "version": "0.81.5", "resolved": "https://registry.npmjs.org/@react-native/assets-registry/-/assets-registry-0.81.5.tgz", @@ -2960,6 +3467,155 @@ "integrity": "sha512-0HuJ8YtqlTVRXGZuGeBejLE04wSQsibpTI+RGOyVqxZvgtlLLC/Ssw0UmbHhT4lYMp2fhdtvKZSs5emWB1zR/g==", "license": "MIT" }, + "node_modules/@react-native/virtualized-lists": { + "version": "0.72.8", + "resolved": "https://registry.npmjs.org/@react-native/virtualized-lists/-/virtualized-lists-0.72.8.tgz", + "integrity": "sha512-J3Q4Bkuo99k7mu+jPS9gSUSgq+lLRSI/+ahXNwV92XgJ/8UgOTxu2LPwhJnBk/sQKxq7E8WkZBnBiozukQMqrw==", + "dev": true, + "license": "MIT", + "dependencies": { + "invariant": "^2.2.4", + "nullthrows": "^1.1.1" + }, + "peerDependencies": { + "react-native": "*" + } + }, + "node_modules/@react-navigation/bottom-tabs": { + "version": "7.16.1", + "resolved": "https://registry.npmjs.org/@react-navigation/bottom-tabs/-/bottom-tabs-7.16.1.tgz", + "integrity": "sha512-wjFATJmbq0K8B96Ax0JcK2+Eu7syfYvQ5qUd/tgcv8JuCYLwKKqojJMAl31qdjpKqFG09pQ6TSdEDHOek60CAA==", + "license": "MIT", + "dependencies": { + "@react-navigation/elements": "^2.9.18", + "color": "^4.2.3", + "sf-symbols-typescript": "^2.1.0" + }, + "peerDependencies": { + "@react-navigation/native": "^7.2.4", + "react": ">= 18.2.0", + "react-native": "*", + "react-native-safe-area-context": ">= 4.0.0", + "react-native-screens": ">= 4.0.0" + } + }, + "node_modules/@react-navigation/core": { + "version": "7.17.4", + "resolved": "https://registry.npmjs.org/@react-navigation/core/-/core-7.17.4.tgz", + "integrity": "sha512-Rv9E2oNNQEkPGpmu9q+vJwGJRSQR6LBg5L+Yo1QHjtwGbHUbjkIKOdYymDZoZYgNzX2OD4rAIlfuzbDKa3cCeA==", + "license": "MIT", + "dependencies": { + "@react-navigation/routers": "^7.5.5", + "escape-string-regexp": "^4.0.0", + "fast-deep-equal": "^3.1.3", + "nanoid": "^3.3.11", + "query-string": "^7.1.3", + "react-is": "^19.1.0", + "use-latest-callback": "^0.2.4", + "use-sync-external-store": "^1.5.0" + }, + "peerDependencies": { + "react": ">= 18.2.0" + } + }, + "node_modules/@react-navigation/core/node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@react-navigation/core/node_modules/react-is": { + "version": "19.2.6", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.6.tgz", + "integrity": "sha512-XjBR15BhXuylgWGuslhDKqlSayuqvqBX91BP8pauG8kd1zY8kotkNWbXksTCNRarse4kuGbe2kIY05ARtwNIvw==", + "license": "MIT" + }, + "node_modules/@react-navigation/elements": { + "version": "2.9.18", + "resolved": "https://registry.npmjs.org/@react-navigation/elements/-/elements-2.9.18.tgz", + "integrity": "sha512-mKEvDr6CkCVYZSb8W9WubNseihL+1c8M7ktZJCTCbMk8rQgdQfkdRNwpSUQKspdGpUHCb9cyzvaiuzl1NtjVgw==", + "license": "MIT", + "dependencies": { + "color": "^4.2.3", + "use-latest-callback": "^0.2.4", + "use-sync-external-store": "^1.5.0" + }, + "peerDependencies": { + "@react-native-masked-view/masked-view": ">= 0.2.0", + "@react-navigation/native": "^7.2.4", + "react": ">= 18.2.0", + "react-native": "*", + "react-native-safe-area-context": ">= 4.0.0" + }, + "peerDependenciesMeta": { + "@react-native-masked-view/masked-view": { + "optional": true + } + } + }, + "node_modules/@react-navigation/native": { + "version": "7.2.4", + "resolved": "https://registry.npmjs.org/@react-navigation/native/-/native-7.2.4.tgz", + "integrity": "sha512-eWC2D3JjhYLId2fVTZhhCiUpWIaPhO9XyEb7Wq8ElmOHyIODlbOzgZ0rKia02OIsDKr9BzZl2sK1dL70yMxDaw==", + "license": "MIT", + "dependencies": { + "@react-navigation/core": "^7.17.4", + "escape-string-regexp": "^4.0.0", + "fast-deep-equal": "^3.1.3", + "nanoid": "^3.3.11", + "use-latest-callback": "^0.2.4" + }, + "peerDependencies": { + "react": ">= 18.2.0", + "react-native": "*" + } + }, + "node_modules/@react-navigation/native-stack": { + "version": "7.15.1", + "resolved": "https://registry.npmjs.org/@react-navigation/native-stack/-/native-stack-7.15.1.tgz", + "integrity": "sha512-kNrJggwoB/onC0MpZIuZ6qaqeAziFchz+W9txBzhd6qbWmB1OkPVUnu6fWgc6BQc7MeMf59djVmqgX+6kJU1Ug==", + "license": "MIT", + "dependencies": { + "@react-navigation/elements": "^2.9.18", + "color": "^4.2.3", + "sf-symbols-typescript": "^2.1.0", + "warn-once": "^0.1.1" + }, + "peerDependencies": { + "@react-navigation/native": "^7.2.4", + "react": ">= 18.2.0", + "react-native": "*", + "react-native-safe-area-context": ">= 4.0.0", + "react-native-screens": ">= 4.0.0" + } + }, + "node_modules/@react-navigation/native/node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@react-navigation/routers": { + "version": "7.5.5", + "resolved": "https://registry.npmjs.org/@react-navigation/routers/-/routers-7.5.5.tgz", + "integrity": "sha512-9/hhMte12Kgu+pMnLfA4EWJ0OQmIEAMVMX06FPH2yGkEQSQ3JhhCN/GkcRikzQhtEi97VYYQA15umptBUShcOQ==", + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11" + } + }, "node_modules/@sinclair/typebox": { "version": "0.27.10", "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.10.tgz", @@ -3034,6 +3690,12 @@ "@types/node": "*" } }, + "node_modules/@types/hammerjs": { + "version": "2.0.46", + "resolved": "https://registry.npmjs.org/@types/hammerjs/-/hammerjs-2.0.46.tgz", + "integrity": "sha512-ynRvcq6wvqexJ9brDMS4BnBLzmr0e14d6ZJTEShTBWKymQiHwlAyGu0ZPEFI2Fh1U53F7tN9ufClWM5KvqkKOw==", + "license": "MIT" + }, "node_modules/@types/istanbul-lib-coverage": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", @@ -3067,6 +3729,27 @@ "undici-types": ">=7.24.0 <7.24.7" } }, + "node_modules/@types/react": { + "version": "19.2.15", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.15.tgz", + "integrity": "sha512-eRwcGNHve+E8qtEQSSRl6urh+rFop4v8gm6O8rGv25CodbvFdLjA1vVQ1KkiFE0w0UPOnb8tDiFKL5lp0rtY5Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-native": { + "version": "0.72.8", + "resolved": "https://registry.npmjs.org/@types/react-native/-/react-native-0.72.8.tgz", + "integrity": "sha512-St6xA7+EoHN5mEYfdWnfYt0e8u6k2FR0P9s2arYgakQGFgU1f9FlPrIEcj0X24pLCF5c5i3WVuLCUdiCYHmOoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@react-native/virtualized-lists": "^0.72.4", + "@types/react": "*" + } + }, "node_modules/@types/stack-utils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz", @@ -3260,6 +3943,18 @@ "sprintf-js": "~1.0.2" } }, + "node_modules/aria-hidden": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz", + "integrity": "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/asap": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz", @@ -3272,6 +3967,49 @@ "integrity": "sha512-csOlWGAcRFJaI6m+F2WKdnMKr4HhdhFVBk0H/QbJFMCr+uO2kwohwXQPxw/9OCxp05r5ghVBFSyioixx3gfkNQ==", "license": "MIT" }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/axios": { + "version": "1.16.1", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.16.1.tgz", + "integrity": "sha512-caYkukvroVPO8KrzuJEb50Hm07KwfBZPEC3VeFHTsqWHvKTsy54hjJz9BS/cdaypROE2rH6xvm9mHX4fgWkr3A==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.16.0", + "form-data": "^4.0.5", + "https-proxy-agent": "^5.0.1", + "proxy-from-env": "^2.1.0" + } + }, + "node_modules/axios/node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/axios/node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/babel-jest": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-29.7.0.tgz", @@ -3737,6 +4475,19 @@ "node": ">= 0.8" } }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/camelcase": { "version": "6.3.0", "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", @@ -3878,6 +4629,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/client-only": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", + "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==", + "license": "MIT" + }, "node_modules/cliui": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", @@ -3901,6 +4658,19 @@ "node": ">=0.8" } }, + "node_modules/color": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz", + "integrity": "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1", + "color-string": "^1.9.0" + }, + "engines": { + "node": ">=12.5.0" + } + }, "node_modules/color-convert": { "version": "1.9.3", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", @@ -3916,6 +4686,46 @@ "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", "license": "MIT" }, + "node_modules/color-string": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz", + "integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==", + "license": "MIT", + "dependencies": { + "color-name": "^1.0.0", + "simple-swizzle": "^0.2.2" + } + }, + "node_modules/color/node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color/node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT" + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/commander": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz", @@ -4048,6 +4858,23 @@ "node": ">= 8" } }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/date-fns": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.2.1.tgz", + "integrity": "sha512-37RhSdxaG1suen6VDCza6rNrQfooyQh57HFVPwQGEq2QWliVLzPQZ8Oa017weOu+HZCnzI7N3Pf/wyoBKfEqrA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/kossnocorp" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -4065,6 +4892,15 @@ } } }, + "node_modules/decode-uri-component": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/decode-uri-component/-/decode-uri-component-0.2.2.tgz", + "integrity": "sha512-FqUYQ+8o158GyGTrMFJms9qh3CqTKvAqgqsTnkLI8sKu0028orqBhxNMFkFen0zGyg6epACD32pjVk58ngIErQ==", + "license": "MIT", + "engines": { + "node": ">=0.10" + } + }, "node_modules/deep-extend": { "version": "0.6.0", "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", @@ -4104,6 +4940,15 @@ "node": ">=8" } }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -4132,6 +4977,12 @@ "node": ">=8" } }, + "node_modules/detect-node-es": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz", + "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==", + "license": "MIT" + }, "node_modules/dotenv": { "version": "16.4.7", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.7.tgz", @@ -4159,6 +5010,20 @@ "url": "https://dotenvx.com" } }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/ee-first": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", @@ -4204,6 +5069,15 @@ "stackframe": "^1.3.4" } }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/es-errors": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", @@ -4213,6 +5087,33 @@ "node": ">= 0.4" } }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", @@ -4320,6 +5221,32 @@ } } }, + "node_modules/expo-constants": { + "version": "18.0.13", + "resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-18.0.13.tgz", + "integrity": "sha512-FnZn12E1dRYKDHlAdIyNFhBurKTS3F9CrfrBDJI5m3D7U17KBHMQ6JEfYlSj7LG7t+Ulr+IKaj58L1k5gBwTcQ==", + "license": "MIT", + "dependencies": { + "@expo/config": "~12.0.13", + "@expo/env": "~2.0.8" + }, + "peerDependencies": { + "expo": "*", + "react-native": "*" + } + }, + "node_modules/expo-local-authentication": { + "version": "17.0.8", + "resolved": "https://registry.npmjs.org/expo-local-authentication/-/expo-local-authentication-17.0.8.tgz", + "integrity": "sha512-Q5fXHhu6w3pVPlFCibU72SYIAN+9wX7QpFn9h49IUqs0Equ44QgswtGrxeh7fdnDqJrrYGPet5iBzjnE70uolA==", + "license": "MIT", + "dependencies": { + "invariant": "^2.2.4" + }, + "peerDependencies": { + "expo": "*" + } + }, "node_modules/expo-modules-autolinking": { "version": "3.0.25", "resolved": "https://registry.npmjs.org/expo-modules-autolinking/-/expo-modules-autolinking-3.0.25.tgz", @@ -4419,6 +5346,163 @@ "react-native": "*" } }, + "node_modules/expo-router": { + "version": "6.0.23", + "resolved": "https://registry.npmjs.org/expo-router/-/expo-router-6.0.23.tgz", + "integrity": "sha512-qCxVAiCrCyu0npky6azEZ6dJDMt77OmCzEbpF6RbUTlfkaCA417LvY14SBkk0xyGruSxy/7pvJOI6tuThaUVCA==", + "license": "MIT", + "dependencies": { + "@expo/metro-runtime": "^6.1.2", + "@expo/schema-utils": "^0.1.8", + "@radix-ui/react-slot": "1.2.0", + "@radix-ui/react-tabs": "^1.1.12", + "@react-navigation/bottom-tabs": "^7.4.0", + "@react-navigation/native": "^7.1.8", + "@react-navigation/native-stack": "^7.3.16", + "client-only": "^0.0.1", + "debug": "^4.3.4", + "escape-string-regexp": "^4.0.0", + "expo-server": "^1.0.5", + "fast-deep-equal": "^3.1.3", + "invariant": "^2.2.4", + "nanoid": "^3.3.8", + "query-string": "^7.1.3", + "react-fast-compare": "^3.2.2", + "react-native-is-edge-to-edge": "^1.1.6", + "semver": "~7.6.3", + "server-only": "^0.0.1", + "sf-symbols-typescript": "^2.1.0", + "shallowequal": "^1.1.0", + "use-latest-callback": "^0.2.1", + "vaul": "^1.1.2" + }, + "peerDependencies": { + "@expo/metro-runtime": "^6.1.2", + "@react-navigation/drawer": "^7.5.0", + "@testing-library/react-native": ">= 12.0.0", + "expo": "*", + "expo-constants": "^18.0.13", + "expo-linking": "^8.0.11", + "react": "*", + "react-dom": "*", + "react-native": "*", + "react-native-gesture-handler": "*", + "react-native-reanimated": "*", + "react-native-safe-area-context": ">= 5.4.0", + "react-native-screens": "*", + "react-native-web": "*", + "react-server-dom-webpack": "~19.0.4 || ~19.1.5 || ~19.2.4" + }, + "peerDependenciesMeta": { + "@react-navigation/drawer": { + "optional": true + }, + "@testing-library/react-native": { + "optional": true + }, + "react-dom": { + "optional": true + }, + "react-native-gesture-handler": { + "optional": true + }, + "react-native-reanimated": { + "optional": true + }, + "react-native-web": { + "optional": true + }, + "react-server-dom-webpack": { + "optional": true + } + } + }, + "node_modules/expo-router/node_modules/@expo/metro-runtime": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/@expo/metro-runtime/-/metro-runtime-6.1.2.tgz", + "integrity": "sha512-nvM+Qv45QH7pmYvP8JB1G8JpScrWND3KrMA6ZKe62cwwNiX/BjHU28Ear0v/4bQWXlOY0mv6B8CDIm8JxXde9g==", + "license": "MIT", + "dependencies": { + "anser": "^1.4.9", + "pretty-format": "^29.7.0", + "stacktrace-parser": "^0.1.10", + "whatwg-fetch": "^3.0.0" + }, + "peerDependencies": { + "expo": "*", + "react": "*", + "react-dom": "*", + "react-native": "*" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/expo-router/node_modules/@radix-ui/react-tabs": { + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.13.tgz", + "integrity": "sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-roving-focus": "1.1.11", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/expo-router/node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/expo-router/node_modules/semver": { + "version": "7.6.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz", + "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/expo-secure-store": { + "version": "15.0.8", + "resolved": "https://registry.npmjs.org/expo-secure-store/-/expo-secure-store-15.0.8.tgz", + "integrity": "sha512-lHnzvRajBu4u+P99+0GEMijQMFCOYpWRO4dWsXSuMt77+THPIGjzNvVKrGSl6mMrLsfVaKL8BpwYZLGlgA+zAw==", + "license": "MIT", + "peerDependencies": { + "expo": "*" + } + }, "node_modules/expo-server": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/expo-server/-/expo-server-1.0.6.tgz", @@ -4748,20 +5832,6 @@ "react-native": "*" } }, - "node_modules/expo/node_modules/expo-constants": { - "version": "18.0.13", - "resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-18.0.13.tgz", - "integrity": "sha512-FnZn12E1dRYKDHlAdIyNFhBurKTS3F9CrfrBDJI5m3D7U17KBHMQ6JEfYlSj7LG7t+Ulr+IKaj58L1k5gBwTcQ==", - "license": "MIT", - "dependencies": { - "@expo/config": "~12.0.13", - "@expo/env": "~2.0.8" - }, - "peerDependencies": { - "expo": "*", - "react-native": "*" - } - }, "node_modules/expo/node_modules/expo-file-system": { "version": "19.0.22", "resolved": "https://registry.npmjs.org/expo-file-system/-/expo-file-system-19.0.22.tgz", @@ -4886,6 +5956,12 @@ "integrity": "sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA==", "license": "Apache-2.0" }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, "node_modules/fast-json-stable-stringify": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", @@ -4913,6 +5989,15 @@ "node": ">=8" } }, + "node_modules/filter-obj": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/filter-obj/-/filter-obj-1.1.0.tgz", + "integrity": "sha512-8rXg1ZnX7xzy2NGDVkBVaAy+lSlPNwad13BtgSlLuxfIslyt5Vg64U7tFcCt4WS1R0hvtnQybT/IyCkGZ3DpXQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/finalhandler": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.1.2.tgz", @@ -4965,12 +6050,48 @@ "integrity": "sha512-3PYnM29RFXwvAN6Pc/scUfkI7RwhQ/xqyLUyPNlXUp9S40zI8nup9tUSrTLSVnWGBN38FNiGWbwZOB6uR4OGdw==", "license": "MIT" }, + "node_modules/follow-redirects": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, "node_modules/fontfaceobserver": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/fontfaceobserver/-/fontfaceobserver-2.3.0.tgz", "integrity": "sha512-6FPvD/IVyT4ZlNe7Wcn5Fb/4ChigpucKYSvD6a+0iMoLn2inpo711eyIcKjmDtE5XNcgAkSH9uN/nfAeZzHEfg==", "license": "BSD-2-Clause" }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/freeport-async": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/freeport-async/-/freeport-async-2.0.0.tgz", @@ -5036,6 +6157,39 @@ "node": "6.* || 8.* || >= 10.*" } }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-nonce": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz", + "integrity": "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/get-package-type": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/get-package-type/-/get-package-type-0.1.0.tgz", @@ -5045,6 +6199,19 @@ "node": ">=8.0.0" } }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/getenv": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/getenv/-/getenv-2.0.0.tgz", @@ -5071,6 +6238,18 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/graceful-fs": { "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", @@ -5086,6 +6265,33 @@ "node": ">=4" } }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/hasown": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", @@ -5113,6 +6319,21 @@ "hermes-estree": "0.32.0" } }, + "node_modules/hoist-non-react-statics": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz", + "integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==", + "license": "BSD-3-Clause", + "dependencies": { + "react-is": "^16.7.0" + } + }, + "node_modules/hoist-non-react-statics/node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "license": "MIT" + }, "node_modules/hosted-git-info": { "version": "7.0.2", "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-7.0.2.tgz", @@ -5258,6 +6479,12 @@ "loose-envify": "^1.0.0" } }, + "node_modules/is-arrayish": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.4.tgz", + "integrity": "sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA==", + "license": "MIT" + }, "node_modules/is-core-module": { "version": "2.16.2", "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.2.tgz", @@ -6198,6 +7425,15 @@ "integrity": "sha512-ocnPZQLNpvbedwTy9kNrQEsknEfgvcLMvOtz3sFeWApDq1MXH1TqkCIx58xlpESsfwQOnuBO9beyQuNGzVvuhQ==", "license": "Apache-2.0" }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/memoize-one": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-5.2.1.tgz", @@ -7188,6 +8424,15 @@ "node": ">= 6" } }, + "node_modules/proxy-from-env": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -7205,6 +8450,24 @@ "qrcode-terminal": "bin/qrcode-terminal.js" } }, + "node_modules/query-string": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/query-string/-/query-string-7.1.3.tgz", + "integrity": "sha512-hh2WYhq4fi8+b+/2Kg9CEge4fDPvHS534aOOvOZeQ3+Vf2mCFsaFBYj0i+iXcAq6I9Vzp5fjMFBlONvayDC1qg==", + "license": "MIT", + "dependencies": { + "decode-uri-component": "^0.2.2", + "filter-obj": "^1.1.0", + "split-on-first": "^1.0.0", + "strict-uri-encode": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/queue": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/queue/-/queue-6.0.2.tgz", @@ -7257,6 +8520,24 @@ "ws": "^7" } }, + "node_modules/react-fast-compare": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-3.2.2.tgz", + "integrity": "sha512-nsO+KSNgo1SbJqJEYRE9ERzo7YtYbou/OqjSQKxV7jcKox7+usiUVZOAC+XnDOABXggQTno0Y1CpVnuWEc1boQ==", + "license": "MIT" + }, + "node_modules/react-freeze": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/react-freeze/-/react-freeze-1.0.4.tgz", + "integrity": "sha512-r4F0Sec0BLxWicc7HEyo2x3/2icUTrRmDjaaRyzzn+7aDyFZliszMDOgLVwSnQnYENOlL1o569Ze2HZefk8clA==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "react": ">=17.0.0" + } + }, "node_modules/react-is": { "version": "18.3.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", @@ -7320,6 +8601,21 @@ } } }, + "node_modules/react-native-gesture-handler": { + "version": "2.28.0", + "resolved": "https://registry.npmjs.org/react-native-gesture-handler/-/react-native-gesture-handler-2.28.0.tgz", + "integrity": "sha512-0msfJ1vRxXKVgTgvL+1ZOoYw3/0z1R+Ked0+udoJhyplC2jbVKIJ8Z1bzWdpQRCV3QcQ87Op0zJVE5DhKK2A0A==", + "license": "MIT", + "dependencies": { + "@egjs/hammerjs": "^2.0.17", + "hoist-non-react-statics": "^3.3.0", + "invariant": "^2.2.4" + }, + "peerDependencies": { + "react": "*", + "react-native": "*" + } + }, "node_modules/react-native-is-edge-to-edge": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/react-native-is-edge-to-edge/-/react-native-is-edge-to-edge-1.3.1.tgz", @@ -7330,6 +8626,76 @@ "react-native": "*" } }, + "node_modules/react-native-paper": { + "version": "5.15.2", + "resolved": "https://registry.npmjs.org/react-native-paper/-/react-native-paper-5.15.2.tgz", + "integrity": "sha512-e0HoKG+G85e5ckM28qElaqiDD+y0uHLVCUmOWe5094OuB9NDX4kKTY3twCVb2PPgA6lB0pQylu/O4Bvyg/gIYA==", + "license": "MIT", + "workspaces": [ + "example", + "docs" + ], + "dependencies": { + "@callstack/react-theme-provider": "^3.0.9", + "color": "^3.1.2", + "use-latest-callback": "^0.2.3" + }, + "peerDependencies": { + "react": "*", + "react-native": "*", + "react-native-safe-area-context": "*" + } + }, + "node_modules/react-native-paper/node_modules/color": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/color/-/color-3.2.1.tgz", + "integrity": "sha512-aBl7dZI9ENN6fUGC7mWpMTPNHmWUSNan9tuWN6ahh5ZLNk9baLJOnSMlrQkHcrfFgz2/RigjUVAjdx36VcemKA==", + "license": "MIT", + "dependencies": { + "color-convert": "^1.9.3", + "color-string": "^1.6.0" + } + }, + "node_modules/react-native-reanimated": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/react-native-reanimated/-/react-native-reanimated-4.1.7.tgz", + "integrity": "sha512-Q4H6xA3Tn7QL0/E/KjI86I1KK4tcf+ErRE04LH34Etka2oVQhW6oXQ+Q8ZcDCVxiWp5vgbBH6XcH8BOo4w/Rhg==", + "license": "MIT", + "dependencies": { + "react-native-is-edge-to-edge": "^1.2.1", + "semver": "^7.7.2" + }, + "peerDependencies": { + "react": "*", + "react-native": "0.78 - 0.82", + "react-native-worklets": "0.5 - 0.8" + } + }, + "node_modules/react-native-safe-area-context": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/react-native-safe-area-context/-/react-native-safe-area-context-5.6.2.tgz", + "integrity": "sha512-4XGqMNj5qjUTYywJqpdWZ9IG8jgkS3h06sfVjfw5yZQZfWnRFXczi0GnYyFyCc2EBps/qFmoCH8fez//WumdVg==", + "license": "MIT", + "peerDependencies": { + "react": "*", + "react-native": "*" + } + }, + "node_modules/react-native-screens": { + "version": "4.16.0", + "resolved": "https://registry.npmjs.org/react-native-screens/-/react-native-screens-4.16.0.tgz", + "integrity": "sha512-yIAyh7F/9uWkOzCi1/2FqvNvK6Wb9Y1+Kzn16SuGfN9YFJDTbwlzGRvePCNTOX0recpLQF3kc2FmvMUhyTCH1Q==", + "license": "MIT", + "dependencies": { + "react-freeze": "^1.0.0", + "react-native-is-edge-to-edge": "^1.2.1", + "warn-once": "^0.1.0" + }, + "peerDependencies": { + "react": "*", + "react-native": "*" + } + }, "node_modules/react-native/node_modules/@react-native/virtualized-lists": { "version": "0.81.5", "resolved": "https://registry.npmjs.org/@react-native/virtualized-lists/-/virtualized-lists-0.81.5.tgz", @@ -7429,6 +8795,75 @@ "node": ">=0.10.0" } }, + "node_modules/react-remove-scroll": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz", + "integrity": "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==", + "license": "MIT", + "dependencies": { + "react-remove-scroll-bar": "^2.3.7", + "react-style-singleton": "^2.2.3", + "tslib": "^2.1.0", + "use-callback-ref": "^1.3.3", + "use-sidecar": "^1.1.3" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-remove-scroll-bar": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.8.tgz", + "integrity": "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==", + "license": "MIT", + "dependencies": { + "react-style-singleton": "^2.2.2", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-style-singleton": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz", + "integrity": "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==", + "license": "MIT", + "dependencies": { + "get-nonce": "^1.0.0", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/regenerate": { "version": "1.4.2", "resolved": "https://registry.npmjs.org/regenerate/-/regenerate-1.4.2.tgz", @@ -7800,12 +9235,33 @@ "node": ">= 0.8" } }, + "node_modules/server-only": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/server-only/-/server-only-0.0.1.tgz", + "integrity": "sha512-qepMx2JxAa5jjfzxG79yPPq+8BuFToHd1hm7kI+Z4zAq1ftQiP7HcxMhDDItrbtwVeLg/cY2JnKnrcFkmiswNA==", + "license": "MIT" + }, "node_modules/setprototypeof": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", "license": "ISC" }, + "node_modules/sf-symbols-typescript": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/sf-symbols-typescript/-/sf-symbols-typescript-2.2.0.tgz", + "integrity": "sha512-TPbeg0b7ylrswdGCji8FRGFAKuqbpQlLbL8SOle3j1iHSs5Ob5mhvMAxWN2UItOjgALAB5Zp3fmMfj8mbWvXKw==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/shallowequal": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/shallowequal/-/shallowequal-1.1.0.tgz", + "integrity": "sha512-y0m1JoUZSlPAjXVtPPW70aZWfIL/dSP7AFkRnniLCrK/8MDKog3TySTBmckD+RObVxH0v4Tox67+F14PdED2oQ==", + "license": "MIT" + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -7856,6 +9312,15 @@ "plist": "^3.0.5" } }, + "node_modules/simple-swizzle": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.4.tgz", + "integrity": "sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw==", + "license": "MIT", + "dependencies": { + "is-arrayish": "^0.3.1" + } + }, "node_modules/sisteransi": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", @@ -7917,6 +9382,15 @@ "node": ">=0.10.0" } }, + "node_modules/split-on-first": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/split-on-first/-/split-on-first-1.1.0.tgz", + "integrity": "sha512-43ZssAJaMusuKWL8sKUBQXHWOpq8d6CfN/u1p4gUzfJkM05C8rxTmYrkIPTXapZpORA6LkkzcUulJ8FqA7Uudw==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/sprintf-js": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", @@ -7980,6 +9454,15 @@ "node": ">= 0.10.0" } }, + "node_modules/strict-uri-encode": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/strict-uri-encode/-/strict-uri-encode-2.0.0.tgz", + "integrity": "sha512-QwiXZgpRcKkhTj2Scnn++4PKtWsH0kpzZ62L2R6c/LUVYv7hVnZqcg2+sMuT6R7Jusu1vviK/MFsu6kNJfWlEQ==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/string-width": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", @@ -8343,6 +9826,12 @@ "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", "license": "Apache-2.0" }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, "node_modules/type-detect": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", @@ -8361,6 +9850,20 @@ "node": ">=8" } }, + "node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, "node_modules/undici": { "version": "6.25.0", "resolved": "https://registry.npmjs.org/undici/-/undici-6.25.0.tgz", @@ -8455,6 +9958,67 @@ "browserslist": ">= 4.21.0" } }, + "node_modules/use-callback-ref": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz", + "integrity": "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/use-latest-callback": { + "version": "0.2.6", + "resolved": "https://registry.npmjs.org/use-latest-callback/-/use-latest-callback-0.2.6.tgz", + "integrity": "sha512-FvRG9i1HSo0wagmX63Vrm8SnlUU3LMM3WyZkQ76RnslpBrX694AdG4A0zQBx2B3ZifFA0yv/BaEHGBnEax5rZg==", + "license": "MIT", + "peerDependencies": { + "react": ">=16.8" + } + }, + "node_modules/use-sidecar": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.3.tgz", + "integrity": "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==", + "license": "MIT", + "dependencies": { + "detect-node-es": "^1.1.0", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/utils-merge": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", @@ -8492,6 +10056,19 @@ "node": ">= 0.8" } }, + "node_modules/vaul": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vaul/-/vaul-1.1.2.tgz", + "integrity": "sha512-ZFkClGpWyI2WUQjdLJ/BaGuV6AVQiJ3uELGk3OYtP+B6yCO7Cmn9vPFXVJkRaGkOJu3m8bQMgtyzNHixULceQA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-dialog": "^1.1.1" + }, + "peerDependencies": { + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc" + } + }, "node_modules/vlq": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/vlq/-/vlq-1.0.1.tgz", @@ -8507,6 +10084,12 @@ "makeerror": "1.0.12" } }, + "node_modules/warn-once": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/warn-once/-/warn-once-0.1.1.tgz", + "integrity": "sha512-VkQZJbO8zVImzYFteBXvBOZEl1qL175WH8VmZcxF2fZAoudNhNDvHi+doCaAEdU2l2vtcIwa2zn0QK5+I1HQ3Q==", + "license": "MIT" + }, "node_modules/wcwidth": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/wcwidth/-/wcwidth-1.0.1.tgz", @@ -8768,6 +10351,35 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } + }, + "node_modules/zustand": { + "version": "5.0.13", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.13.tgz", + "integrity": "sha512-efI2tVaVQPqtOh114loML/Z80Y4NP3yc+Ff0fYiZJPauNeWZeIp/bRFD7I9bfmCOYBh/PHxlglQ9+wvlwnPikQ==", + "license": "MIT", + "engines": { + "node": ">=12.20.0" + }, + "peerDependencies": { + "@types/react": ">=18.0.0", + "immer": ">=9.0.6", + "react": ">=18.0.0", + "use-sync-external-store": ">=1.2.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + }, + "use-sync-external-store": { + "optional": true + } + } } } } diff --git a/package.json b/package.json index b03e151..eeb893a 100644 --- a/package.json +++ b/package.json @@ -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" + } } diff --git a/src/api/client.ts b/src/api/client.ts new file mode 100644 index 0000000..f6798d4 --- /dev/null +++ b/src/api/client.ts @@ -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 => { + 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(); diff --git a/src/api/executions.ts b/src/api/executions.ts new file mode 100644 index 0000000..22433c2 --- /dev/null +++ b/src/api/executions.ts @@ -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; + 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; + 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> => { + const response = await apiClient.get>('/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 => { + const response = await apiClient.get(`/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 => { + await apiClient.delete(`/api/v1/executions/${id}`); +}; diff --git a/src/api/workflows.ts b/src/api/workflows.ts new file mode 100644 index 0000000..ce394a6 --- /dev/null +++ b/src/api/workflows.ts @@ -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; + 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; + settings?: Record; + 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 { + 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 => { + const response = await apiClient.get>('/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 => { + const response = await apiClient.get(`/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 => { + const response = await apiClient.post(`/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 => { + const response = await apiClient.post(`/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; +} + +/** + * 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; +}; diff --git a/src/components/ExecutionCard.tsx b/src/components/ExecutionCard.tsx new file mode 100644 index 0000000..a04156b --- /dev/null +++ b/src/components/ExecutionCard.tsx @@ -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 = ({ execution, onViewLogs, onDelete }) => { + const theme = useTheme(); + + return ( + + + {/* En-tête : nom du workflow et badge de statut */} + + + {execution.workflowName ?? `Workflow ${execution.workflowId}`} + + + + + {/* Métadonnées : mode de déclenchement, durée, date */} + + + {formatMode(execution.mode)} + + + {formatDuration(execution.startedAt, execution.stoppedAt)} + + + {formatShortDate(execution.startedAt)} + + + + + {/* Actions : consulter les logs et supprimer */} + + onViewLogs(execution.id)} + accessibilityLabel="Voir les logs de cette exécution" + /> + onDelete(execution.id)} + accessibilityLabel="Supprimer cette exécution" + /> + + + ); +}; + +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; diff --git a/src/components/SkeletonLoader.tsx b/src/components/SkeletonLoader.tsx new file mode 100644 index 0000000..48ad413 --- /dev/null +++ b/src/components/SkeletonLoader.tsx @@ -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 = ({ + 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 ( + + ); +}; + +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 = ({ count = 5 }) => { + return ( + <> + {Array.from({ length: count }).map((_, index) => ( + + + + + + ))} + + ); +}; + +const styles = StyleSheet.create({ + card: { + backgroundColor: '#1E1E1E', + marginHorizontal: 16, + marginVertical: 6, + padding: 16, + borderRadius: 12, + }, + skeleton: { + backgroundColor: '#2C2C2C', + }, +}); + +export default SkeletonLoader; diff --git a/src/components/StatusBadge.tsx b/src/components/StatusBadge.tsx new file mode 100644 index 0000000..f9f446c --- /dev/null +++ b/src/components/StatusBadge.tsx @@ -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 = { + success: '#1B5E20', + error: '#7F0000', + waiting: '#E65100', + running: '#0D47A1', + canceled: '#424242', +}; + +/** Couleurs de texte assorties à chaque fond */ +const STATUS_TEXT_COLORS: Record = { + 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 = ({ status, size = 'default' }) => { + const backgroundColor = STATUS_BG_COLORS[status] ?? '#424242'; + const textColor = STATUS_TEXT_COLORS[status] ?? '#BDBDBD'; + const fontSize = size === 'small' ? 10 : 12; + + return ( + + + {formatStatus(status)} + + + ); +}; + +const styles = StyleSheet.create({ + badge: { + paddingHorizontal: 8, + paddingVertical: 3, + borderRadius: 12, + alignSelf: 'flex-start', + }, + text: { + fontWeight: '600', + letterSpacing: 0.3, + }, +}); + +export default StatusBadge; diff --git a/src/components/WorkflowCard.tsx b/src/components/WorkflowCard.tsx new file mode 100644 index 0000000..b0998e3 --- /dev/null +++ b/src/components/WorkflowCard.tsx @@ -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 = ({ workflow, onToggle, onRun }) => { + const theme = useTheme(); + + return ( + + + {/* En-tête : nom du workflow et switch actif/inactif */} + + + {workflow.name} + + onToggle(workflow)} + color={theme.colors.primary} + /> + + + {/* Date de dernière modification */} + + Modifié {formatRelativeDate(workflow.updatedAt)} + + + {/* Tags optionnels */} + {workflow.tags && workflow.tags.length > 0 && ( + + {workflow.tags.map((tag) => ( + + + {tag.name} + + + ))} + + )} + + + {/* Bouton de déclenchement manuel */} + + onRun(workflow.id)} + accessibilityLabel="Déclencher manuellement ce workflow" + /> + + + ); +}; + +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; diff --git a/src/hooks/useExecutions.ts b/src/hooks/useExecutions.ts new file mode 100644 index 0000000..58f9320 --- /dev/null +++ b/src/hooks/useExecutions.ts @@ -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; + /** + * 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; + /** + * 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; + /** + * 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([]); + const [isLoading, setIsLoading] = useState(true); + const [isRefreshing, setIsRefreshing] = useState(false); + const [selectedExecution, setSelectedExecution] = useState(null); + const [statusFilter, setStatusFilter] = useState( + options.statusFilter + ); + + /** Charge la liste avec les filtres courants */ + const loadExecutions = useCallback(async (): Promise => { + 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 => { + 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 => { + 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 => { + 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, + }; +}; diff --git a/src/hooks/usePolling.ts b/src/hooks/usePolling.ts new file mode 100644 index 0000000..dcb9f85 --- /dev/null +++ b/src/hooks/usePolling.ts @@ -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; + /** 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 | 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 }; +}; diff --git a/src/hooks/useWorkflows.ts b/src/hooks/useWorkflows.ts new file mode 100644 index 0000000..abc86a8 --- /dev/null +++ b/src/hooks/useWorkflows.ts @@ -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; + /** + * 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; + /** + * Déclenche manuellement l'exécution d'un workflow. + * + * @param id - Identifiant du workflow à déclencher + */ + triggerWorkflow: (id: string) => Promise; +} + +/** + * 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([]); + const [isLoading, setIsLoading] = useState(true); + const [isRefreshing, setIsRefreshing] = useState(false); + + /** Charge la liste des workflows depuis la couche API */ + const loadWorkflows = useCallback(async (): Promise => { + 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 => { + 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 => { + 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 => { + try { + await runWorkflow(id); + } catch { + // Géré par l'intercepteur Axios + } + }, []); + + return { + workflows, + isLoading, + isRefreshing, + refresh, + toggleWorkflow, + triggerWorkflow, + }; +}; diff --git a/src/screens/DashboardScreen.tsx b/src/screens/DashboardScreen.tsx new file mode 100644 index 0000000..c9c92c6 --- /dev/null +++ b/src/screens/DashboardScreen.tsx @@ -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 = ({ label, value, subtitle, valueColor }) => { + const theme = useTheme(); + return ( + + + + {label} + + + {value} + + + {subtitle} + + + + ); +}; + +/** + * É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 ( + + } + > + + Tableau de bord + + + {isLoading ? ( + + ) : ( + <> + {/* Métriques rapides */} + + + + + + {/* Section dernières exécutions */} + + Exécutions récentes + + + {executions.length === 0 ? ( + + Aucune exécution récente. + + ) : ( + executions.slice(0, 5).map((exec) => ( + + + + {exec.workflowName ?? `Workflow ${exec.workflowId}`} + + + + + + {formatShortDate(exec.startedAt)} + + + + )) + )} + + )} + + ); +}; + +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; diff --git a/src/screens/ExecutionsScreen.tsx b/src/screens/ExecutionsScreen.tsx new file mode 100644 index 0000000..20c30b6 --- /dev/null +++ b/src/screens/ExecutionsScreen.tsx @@ -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(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 => { + await removeExecution(id); + setSnackMessage('Exécution supprimée'); + }; + + return ( + + {/* Barre de filtres de statut horizontale */} + + {STATUS_FILTERS.map((filter) => ( + handleFilterChange(filter.value)} + style={styles.chip} + compact + > + {filter.label} + + ))} + + + {isLoading ? ( + + ) : ( + item.id} + renderItem={({ item }) => ( + router.push(`/execution/${id}` as `/${string}`)} + onDelete={handleDelete} + /> + )} + refreshing={isRefreshing} + onRefresh={refresh} + contentContainerStyle={styles.list} + ListEmptyComponent={ + + Aucune exécution trouvée. + + } + /> + )} + + setSnackMessage('')} + duration={2500} + > + {snackMessage} + + + ); +}; + +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; diff --git a/src/screens/LogsScreen.tsx b/src/screens/LogsScreen.tsx new file mode 100644 index 0000000..cde9538 --- /dev/null +++ b/src/screens/LogsScreen.tsx @@ -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 ( + + + + ); + } + + if (!selectedExecution) { + return ( + + + Exécution introuvable ou données indisponibles. + + + ); + } + + const runData = selectedExecution.data?.resultData?.runData ?? {}; + const globalError = selectedExecution.data?.resultData?.error; + const nodeNames = Object.keys(runData); + + return ( + + {/* En-tête : résumé de l'exécution */} + + + + + {selectedExecution.workflowName ?? `Workflow ${selectedExecution.workflowId}`} + + + + + {formatMode(selectedExecution.mode)} ·{' '} + {formatShortDate(selectedExecution.startedAt)} ·{' '} + {formatDuration(selectedExecution.startedAt, selectedExecution.stoppedAt)} + + + + + {/* Erreur globale de l'exécution (niveau workflow) */} + {globalError && ( + + + + Erreur workflow + + + {globalError.message} + + + + )} + + {/* Liste des nœuds exécutés */} + + Nœuds exécutés ({nodeNames.length}) + + + {nodeNames.map((nodeName, index) => { + const nodeRuns: NodeExecutionData[] = runData[nodeName] ?? []; + const firstRun = nodeRuns[0]; + const hasError = !!firstRun?.error; + + return ( + + + {/* Nom du nœud et durée d'exécution */} + + + {index + 1}. {nodeName} + + {firstRun && ( + + {firstRun.executionTime}ms + + )} + + + {/* Erreur spécifique à ce nœud */} + {firstRun?.error && ( + <> + + + {firstRun.error.message} + + + )} + + + ); + })} + + {nodeNames.length === 0 && ( + + Aucune donnée de nœud disponible pour cette exécution. + + )} + + ); +}; + +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; diff --git a/src/screens/SettingsScreen.tsx b/src/screens/SettingsScreen.tsx new file mode 100644 index 0000000..91e74f2 --- /dev/null +++ b/src/screens/SettingsScreen.tsx @@ -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 => { + 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 ( + + {/* Section : connexion à l'instance n8n */} + + Connexion n8n + + + { + setBaseUrl(v); + if (urlError) validateUrl(v); + }} + onBlur={() => baseUrl && validateUrl(baseUrl)} + error={!!urlError} + mode="outlined" + style={styles.input} + autoCapitalize="none" + autoCorrect={false} + keyboardType="url" + left={} + /> + {!!urlError && ( + + {urlError} + + )} + + } + placeholder={config.isConfigured ? '••••••••••••••••' : undefined} + /> + + } + placeholder={config.isConfigured ? '••••••••••••••••' : undefined} + /> + + + + + + {/* Section : préférences utilisateur */} + + Préférences + + + {/* Biométrie — affichée uniquement si le hardware est disponible */} + {isBiometricAvailable && ( + + + Déverrouillage biométrique + + setPreferences({ biometricEnabled: v })} + color={theme.colors.primary} + /> + + )} + + {/* Intervalle de rafraîchissement automatique */} + + Intervalle de rafraîchissement + + + {POLLING_INTERVALS.map((item) => ( + + ))} + + + + + {/* Zone de danger — réinitialisation */} + + Zone de danger + + + + setSnackMessage('')} + duration={3000} + > + {snackMessage} + + + ); +}; + +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; diff --git a/src/screens/WorkflowsScreen.tsx b/src/screens/WorkflowsScreen.tsx new file mode 100644 index 0000000..d07360b --- /dev/null +++ b/src/screens/WorkflowsScreen.tsx @@ -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 => { + 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 => { + await toggleWorkflow(workflow); + setSnackMessage(workflow.active ? 'Workflow désactivé' : 'Workflow activé'); + }; + + return ( + + {isLoading ? ( + + ) : ( + item.id} + renderItem={({ item }) => ( + + )} + refreshing={isRefreshing} + onRefresh={refresh} + contentContainerStyle={styles.list} + ListEmptyComponent={ + + Aucun workflow trouvé. Vérifiez la connexion à l'instance n8n. + + } + /> + )} + + {/* Confirmation d'action non destructive */} + setSnackMessage('')} + duration={2500} + > + {snackMessage} + + + ); +}; + +const styles = StyleSheet.create({ + container: { + flex: 1, + }, + list: { + paddingVertical: 8, + paddingBottom: 80, + }, + empty: { + textAlign: 'center', + marginTop: 48, + marginHorizontal: 32, + fontStyle: 'italic', + }, +}); + +export default WorkflowsScreen; diff --git a/src/store/appStore.ts b/src/store/appStore.ts new file mode 100644 index 0000000..b6454c5 --- /dev/null +++ b/src/store/appStore.ts @@ -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; + + /** + * Charge la configuration au démarrage depuis le secure store. + * Appelé une seule fois par le root layout (_layout.tsx). + */ + loadConfig: () => Promise; + + /** + * Met à jour les préférences utilisateur de manière partielle. + * + * @param prefs - Sous-ensemble des préférences à modifier + */ + setPreferences: (prefs: Partial) => 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((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 }), +})); diff --git a/src/utils/errorHandler.ts b/src/utils/errorHandler.ts new file mode 100644 index 0000000..b6bfdb4 --- /dev/null +++ b/src/utils/errorHandler.ts @@ -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'); +}; diff --git a/src/utils/formatters.ts b/src/utils/formatters.ts new file mode 100644 index 0000000..de13dbc --- /dev/null +++ b/src/utils/formatters.ts @@ -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 = { + 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 = { + manual: 'Manuel', + trigger: 'Déclencheur', + webhook: 'Webhook', + internal: 'Interne', + retry: 'Réessai', + }; + return modeMap[mode] ?? mode; +}; diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..299cf8c --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,15 @@ +{ + "extends": "expo/tsconfig.base", + "compilerOptions": { + "strict": true, + "paths": { + "@/*": ["./src/*"] + } + }, + "include": [ + "**/*.ts", + "**/*.tsx", + ".expo/types/**/*.d.ts", + "expo-env.d.ts" + ] +}