diff --git a/src/App.jsx b/src/App.jsx index e891607..5ba0454 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -17,7 +17,8 @@ import Home from './pages/public/Home'; // Import des Pages Privées (Espace Client) import Dashboard from './pages/app/Dashboard'; import Store from './pages/app/Store'; -//import Services from './pages/app/Services'; +import Checkout from './pages/app/Checkout'; +import Services from './pages/app/Services'; export default function App() { return ( @@ -44,7 +45,8 @@ export default function App() { }> } /> } /> - {/* } /> */} + } /> + } /> diff --git a/src/pages/app/Checkout.jsx b/src/pages/app/Checkout.jsx new file mode 100644 index 0000000..861a432 --- /dev/null +++ b/src/pages/app/Checkout.jsx @@ -0,0 +1,195 @@ +import { useState, useEffect } from 'react'; +import { useParams, useSearchParams, useNavigate } from 'react-router-dom'; +import { resetCart, addToCart, checkoutCart, getProductList, getClientProfile } from '../../services/api'; + +export default function Checkout() { + const { productId } = useParams(); + const [searchParams] = useSearchParams(); + const period = searchParams.get('period'); + const navigate = useNavigate(); + + const [product, setProduct] = useState(null); + const [userProfile, setUserProfile] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [isProcessing, setIsProcessing] = useState(false); + const [error, setError] = useState(null); + + // Chargement asynchrone sécurisé + useEffect(() => { + const loadData = async () => { + try { + // 1. On charge d'abord le produit (Requête Publique) + const productData = await getProductList(); + const foundProduct = productData.list?.find(p => p.id === parseInt(productId)); + + if (!foundProduct) { + setError("Le produit demandé n'existe pas dans le catalogue."); + setIsLoading(false); + return; + } + setProduct(foundProduct); + + // 2. Ensuite, on tente de charger le profil (Requête Privée) + try { + const profileData = await getClientProfile(); + setUserProfile(profileData); + } catch (profileErr) { + // Si on tombe ici, c'est que FOSSBilling refuse l'accès au profil. + console.error("Rejet API Profil :", profileErr); + setError("Accès refusé. Vous devez être connecté à votre compte pour provisionner une instance."); + // Tu pourras décommenter la ligne suivante plus tard pour forcer la redirection : + // navigate('/login'); + setIsLoading(false); + return; + } + + } catch (err) { + // Erreur serveur globale (FOSSBilling hors ligne) + console.error("Erreur Globale Serveur :", err); + setError(`Impossible de contacter le serveur de facturation : ${err.message}`); + } finally { + setIsLoading(false); + } + }; + loadData(); + }, [productId]); + + // Outil de traduction des catégories FOSSBilling + // Vérifie que ces ID correspondent bien à ce que tu as dans ton FOSSBilling + const getCategoryTag = (categoryId) => { + const map = { + 1: 'web', // Hébergement Web + 4: 'vps', // Serveurs VPS + 2: 'db', // Base de données + 3: 'cloud' // Cloud Privé + }; + return map[categoryId] || 'srv'; // 'srv' en solution de secours + }; + + // Exécution de la commande sur FOSSBilling + const handleConfirmOrder = async () => { + setIsProcessing(true); + setError(null); + try { + await resetCart(); + + // ⚙️ NOUVEL ALGORITHME D'ISOLATION UNITAIRE + + // 1. Nettoyage strict du prénom (uniquement lettres et chiffres, pas de tirets) + let cleanName = 'user'; + if (userProfile && userProfile.first_name) { + cleanName = userProfile.first_name + .toLowerCase() + .normalize("NFD").replace(/[\u0300-\u036f]/g, "") // Supprime les accents + .replace(/[^a-z0-9]/g, ""); // Supprime tout le reste (espaces, tirets, etc.) + } + + // 2. Récupération du tag de catégorie (web, vps, db, cloud) + const catTag = getCategoryTag(product.product_category_id); + + // 3. Génération d'une chaîne aléatoire alphanumérique de 4 caractères + const chars = 'abcdefghijklmnopqrstuvwxyz0123456789'; + let randomHash = ''; + for (let i = 0; i < 4; i++) { + randomHash += chars.charAt(Math.floor(Math.random() * chars.length)); + } + + // 4. Création du nom unique pour le domaine (ex: adam-web-b4m2) + const serverName = `${cleanName}-${catTag}-${randomHash}`; + const fullServerName = `${serverName}.gise.be`; + + // ⚙️ STRUCTURE D'INJECTION POUR FOSSBILLING & HESTIACP + const productConfig = { + hostname: fullServerName, + + domain: { + action: 'register', + register_sld: serverName, // Devient l'identifiant unique de la commande + register_tld: '.gise.be', + register_years: 1 + } + }; + + await addToCart(productId, period, productConfig); + await checkoutCart(); + + navigate('/dashboard', { + state: { successMessage: `Instance initialisée sur ${serverName}.gise.be` } + }); + + } catch (err) { + setError(err.message || "La transaction a échoué. L'API a refusé le contrat."); + } finally { + setIsProcessing(false); + } + }; + + if (isLoading) { + return ( +
+

ANALYSE DU CONTRAT EN COURS...

+
+ ); + } + + return ( +
+

CONFIRMATION DE COMMANDE

+ + {error && ( +
+ ERREUR: {error} +
+ )} + + {product && ( +
+
+
+ Instance sélectionnée + {product.title} +
+ +
+ Cycle de facturation + + {period === '1W' ? 'Hebdomadaire (7 jours)' : period === '1M' ? 'Mensuel (30 jours)' : 'Annuel (365 jours)'} + +
+ +
+ Total à régler immédiatement : + + {product.pricing.recurrent[period]?.price} € + +
+
+ +
+ En validant cette commande, vous autorisez le Terminal Nexus à provisionner les ressources matérielles. Un domaine sécurisé .gise.be vous sera automatiquement attribué. +
+ +
+ + + +
+
+ )} +
+ ); +} \ No newline at end of file diff --git a/src/pages/app/Dashboard.jsx b/src/pages/app/Dashboard.jsx index e468094..19563f5 100644 --- a/src/pages/app/Dashboard.jsx +++ b/src/pages/app/Dashboard.jsx @@ -13,11 +13,31 @@ export default function Dashboard() { useEffect(() => { const fetchInventory = async () => { try { - // Appel à FOSSBilling via notre api.js const data = await getClientOrders(); - // FOSSBilling renvoie souvent la liste dans data.list - setOrders(data.list || []); + if (data.list) { + // LE FILTRE CHIRURGICAL PAR PREFIXE + const filteredOrders = data.list.filter(order => { + const title = (order.title || '').toLowerCase(); + const type = (order.type || '').toLowerCase(); + + // On masque la commande SI : + // 1. Le type technique est "domain" + // 2. OU le titre COMMENCE par "domain", "domaine" ou "enregistrement" + const isGhostProduct = + type === 'domain' || + title.startsWith('domain ') || + title.startsWith('domaine ') || + title.startsWith('enregistrement '); + + // On retourne true (on affiche) uniquement si ce n'est pas un produit fantôme + return !isGhostProduct; + }); + + setOrders(filteredOrders); + } else { + setOrders([]); + } } catch (err) { setError(err.message || "Impossible de récupérer la télémétrie des services."); } finally { diff --git a/src/pages/app/Services.jsx b/src/pages/app/Services.jsx index e69de29..beb095a 100644 --- a/src/pages/app/Services.jsx +++ b/src/pages/app/Services.jsx @@ -0,0 +1,293 @@ +import { useState, useEffect } from 'react'; +import { getMyServices, getServiceDetails, getHostingServiceDetails, resetHostingPassword, launchSSOGateway } from '../../services/api'; +import { useVPC } from '../../services/useVPC'; +import { Server, Database, Cloud, Globe, Folder, Trash2, ExternalLink, Loader, Plus } from 'lucide-react'; + +export default function Services() { + const [services, setServices] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + const [newVpcName, setNewVpcName] = useState(""); + const [isConnecting, setIsConnecting] = useState(null); // Pour le loader du bouton Auto-Login + const [ssoVault, setSsoVault] = useState(null); + + // Branchement du moteur logique VPC + const { vpcs, createVPC, deleteVPC, assignToVPC, removeFromVPC } = useVPC(); + + // 1. Récupération et Filtrage des Infrastructures + useEffect(() => { + const fetchServices = async () => { + try { + const data = await getMyServices(); + + if (data.list && data.list.length > 0) { + // On récupère les mots de passe de chaque service pour le bouton Auto-Login + const detailedServices = await Promise.all( + data.list.map(async (order) => { + const details = await getServiceDetails(order.id); + return details; + }) + ); + + // LE FILTRE CHIRURGICAL : On exclut les produits "Domaine" fantômes + const filteredServices = detailedServices.filter(s => { + const type = (s.type || '').toLowerCase(); + const title = (s.title || '').toLowerCase(); + const isGhostProduct = + type === 'domain' || + title.startsWith('domain ') || + title.startsWith('domaine ') || + title.startsWith('enregistrement '); + + // On garde les actifs/en préparation qui ne sont pas des domaines + return (s.status === 'active' || s.status === 'pending_setup') && !isGhostProduct; + }); + + setServices(filteredServices); + } + } catch (err) { + setError(err.message || "Impossible de charger la télémétrie des services."); + } finally { + setIsLoading(false); + } + }; + fetchServices(); + }, []); + + // 2. Moteur de Connexion Furtive (SSO HestiaCP) avec contournement du bloqueur de pop-up + const handleAutoLogin = async (service) => { + setIsConnecting(service.id); + + try { + // 1. Récupération de l'utilisateur + const hostingDetails = await getHostingServiceDetails(service.id); + const username = hostingDetails.username; + if (!username) throw new Error("Infrastructure non synchronisée avec le métal."); + + // 2. Ghost Reset (Génération du mot de passe jetable) + const secureHash = Math.random().toString(36).slice(-8) + Math.random().toString(36).slice(-4).toUpperCase(); + const rollingPassword = `Nx${secureHash}`; + + console.log("Ghost Reset généré pour", username); + await resetHostingPassword(service.id, rollingPassword); + + // 3. On affiche le Coffre-Fort à l'utilisateur + setSsoVault({ + username: username, + password: rollingPassword, + url: 'https://panel.gise.be/login/' + }); + + } catch (err) { + alert("Échec du protocole d'accès : " + err.message); + } finally { + setIsConnecting(null); + } + }; + + // Utilitaires UI + const getServiceIcon = (title) => { + const t = (title || '').toLowerCase(); + if (t.includes('vps') || t.includes('serveur')) return ; + if (t.includes('cloud') || t.includes('nextcloud')) return ; + if (t.includes('db') || t.includes('base') || t.includes('sql')) return ; + return ; + }; + + // Composant Interne : La Carte d'Instance + const InstanceCard = ({ service }) => ( +
+
+
+
+ {getServiceIcon(service.title)} +
+ {service.status === 'active' ? ( + ONLINE + ) : ( + DEPLOYING + )} +
+ +

{service.title}

+

{service.domain || `ID: #${service.id}`}

+
+ +
+ {/* Sélecteur VPC */} +
+ Projet: + +
+ + {/* Bouton d'accès Auto-Login */} + +
+
+ ); + + // Tri des instances (Libres vs Assignées) + const assignedServiceIds = vpcs.flatMap(vpc => vpc.services); + const freeServices = services.filter(s => !assignedServiceIds.includes(s.id)); + + if (isLoading) return
ANALYSE DU RÉSEAU...
; + + return ( +
+ + {/* EN-TÊTE ET CRÉATION VPC */} +
+
+

INVENTAIRE RÉSEAU

+

Orchestration des environnements et des Virtual Private Clouds.

+
+ +
+ setNewVpcName(e.target.value)} + className="bg-black border border-gray-800 rounded-lg px-4 py-2 text-sm text-white focus:outline-none focus:border-cyan-400 w-64" + /> + +
+
+ + {error &&
{error}
} + + {/* LES VPC (Groupes de projets) */} +
+ {vpcs.map(vpc => { + const vpcServices = services.filter(s => vpc.services.includes(s.id)); + + return ( +
+
+
+ +

{vpc.name.toUpperCase()}

+ {vpcServices.length} INSTANCES +
+ +
+ +
+ {vpcServices.length === 0 ? ( +
+ Réseau virtuel vide. Assigner des instances depuis le pool libre. +
+ ) : ( + vpcServices.map(service => ) + )} +
+
+ ); + })} +
+ + {/* LE POOL LIBRE (Instances non groupées) */} +
+

+ POOL D'INSTANCES LIBRES +

+ +
+ {freeServices.length === 0 ? ( +
+ Aucune instance libre. Toutes vos accréditations sont assignées à des VPC. +
+ ) : ( + freeServices.map(service => ) + )} +
+
+ {/* MODAL DU COFFRE-FORT ÉPHÉMÈRE */} + {ssoVault && ( +
+
+ +
+

ACCÈS AUTORISÉ

+ +
+ +

+ Le pare-feu HestiaCP bloque les injections de session directes. Un mot de passe de session jetable vient d'être généré sur le métal. Copiez-le et connectez-vous. +

+ +
+
+ +
+ {ssoVault.username} + +
+
+ +
+ +
+ {ssoVault.password} + +
+
+
+ + + +
+
+ )} +
+ ); +} \ No newline at end of file diff --git a/src/pages/app/Store.jsx b/src/pages/app/Store.jsx index 25b1da2..b58b2b9 100644 --- a/src/pages/app/Store.jsx +++ b/src/pages/app/Store.jsx @@ -178,7 +178,7 @@ const ProductCard = ({ product, selectedPeriod, categoryName }) => {