retrieve the docv code source instead of skeleton

This commit is contained in:
Sadrinho27 2025-09-29 23:34:38 +02:00
parent aedd3b9f10
commit d5ac9879bb
20 changed files with 1587 additions and 1876 deletions

371
README.md
View File

@ -1,372 +1 @@
# 🛡️ DocV - GED Souveraine et Sécurisée
> **Une approche révolutionnaire de la gestion documentaire avec sécurité, souveraineté et conformité garanties.**
[![Version](https://img.shields.io/badge/version-0.1.0-blue.svg)](VERSION)
[![Next.js](https://img.shields.io/badge/Next.js-15.2.4-black.svg)](https://nextjs.org/)
[![TypeScript](https://img.shields.io/badge/TypeScript-5.0-blue.svg)](https://www.typescriptlang.org/)
[![Tailwind CSS](https://img.shields.io/badge/Tailwind%20CSS-4.1.9-38B2AC.svg)](https://tailwindcss.com/)
[![License](https://img.shields.io/badge/license-Private-red.svg)](#license)
## 📋 Table des Matières
- [🎯 Vue d'ensemble](#-vue-densemble)
- [✨ Fonctionnalités](#-fonctionnalités)
- [🚀 Installation Rapide](#-installation-rapide)
- [⚙️ Configuration](#-configuration)
- [🔧 Commandes de Développement](#-commandes-de-développement)
- [📚 Documentation](#-documentation)
- [🏗️ Architecture](#-architecture)
- [🔒 Sécurité](#-sécurité)
- [🤝 Contribution](#-contribution)
- [📞 Support](#-support)
## 🎯 Vue d'ensemble
**DocV** est une plateforme de gestion documentaire (GED) révolutionnaire qui combine :
- **🔐 Authentification cryptographique** sans mots de passe
- **🤖 IA embarquée** pour l'OCR et la classification
- **🌐 Architecture souveraine** sans dépendance cloud
- **⚡ Interface conversationnelle** pour le suivi des dossiers
- **🔗 Ancrage blockchain** pour la traçabilité
### 🎯 Cas d'Usage Principaux
- **Entreprises** : Gestion documentaire sécurisée
- **Notaires** : Échanges documentaires via lecoffre.io
- **Secteur public** : Conformité et souveraineté des données
- **Éditeurs** : Intégration marque blanche
## ✨ Fonctionnalités
### 🔑 Authentification Ultra-Simplifiée
- ✅ Aucun mot de passe requis
- ✅ Aucun OTP ou code SMS
- ✅ Aucune application mobile
- ✅ Identité auto-générée et auto-portée
### 🤖 Intelligence Artificielle Locale
- ✅ OCR automatique des documents
- ✅ Classification intelligente
- ✅ Extraction de données
- ✅ Interface conversationnelle
- ✅ Traitements 100% locaux
### 🛡️ Sécurité de Bout en Bout
- ✅ Chiffrement natif
- ✅ Aucune interface admin exposée
- ✅ Aucun serveur d'identité
- ✅ Aucune dépendance cloud
- ✅ Conformité RGPD, ISO 27001, SecNumCloud
### 🌐 Architecture Souveraine
- ✅ Déploiement local
- ✅ Migration automatisée
- ✅ Compatible bases existantes
- ✅ APIs souveraines
- ✅ Accompagnement personnalisé
## 🚀 Installation Rapide
### 📋 Prérequis
- **Node.js** : Version 18.0+ (recommandé 20.x)
- **npm** ou **pnpm** : Gestionnaire de paquets
- **Git** : Pour le clonage du repository
### 1⃣ Cloner le Repository
```bash
# Cloner le projet
git clone <REPO_URL>
cd docv
# Installer les dépendances
npm install
# ou
pnpm install
```
### 2⃣ Configuration d'Environnement
```bash
# Créer le fichier d'environnement
cp .env.example .env.local
# Éditer les variables d'environnement
nano .env.local
```
**Variables essentielles :**
```env
# Configuration de base
NEXT_PUBLIC_APP_NAME=DocV
NEXT_PUBLIC_APP_VERSION=0.1.0
# Base de données (si applicable)
DATABASE_URL=your_database_url
# Authentification
NEXTAUTH_SECRET=your_secret_key
NEXTAUTH_URL=http://localhost:3000
# Services externes (optionnels)
EMAIL_SERVICE_API_KEY=your_email_api_key
```
### 3⃣ Démarrage en Mode Développement
```bash
# Démarrer le serveur de développement
npm run dev
# ou
pnpm dev
# L'application sera disponible sur http://localhost:3000
```
## ⚙️ Configuration
### 🎨 Configuration de l'Interface
Le projet utilise **Tailwind CSS** avec des composants **Radix UI** pour une interface moderne et accessible.
```bash
# Fichier de configuration Tailwind
tailwind.config.js
# Composants UI personnalisés
components/ui/
```
### 🔧 Configuration TypeScript
```bash
# Configuration TypeScript
tsconfig.json
# Types personnalisés
types/
```
### 📱 Configuration Responsive
L'interface s'adapte automatiquement aux différentes tailles d'écran :
- 📱 Mobile (< 768px)
- 📟 Tablet (768px - 1024px)
- 💻 Desktop (> 1024px)
## 🔧 Commandes de Développement
### 🚀 Commandes Principales
```bash
# Développement
npm run dev # Serveur de développement (port 3000)
npm run build # Build de production
npm run start # Serveur de production
npm run lint # Vérification du code
# Tests (si configurés)
npm run test # Tests unitaires
npm run test:watch # Tests en mode watch
npm run test:coverage # Tests avec couverture
# Maintenance
npm run clean # Nettoyer les fichiers temporaires
npm run type-check # Vérification TypeScript
```
### 🛠️ Commandes de Maintenance
```bash
# Mise à jour des dépendances
npm update # Mise à jour des paquets
npm audit # Audit de sécurité
npm audit fix # Correction automatique
# Gestion des dépendances
npm install <package> # Installer un paquet
npm uninstall <package> # Désinstaller un paquet
npm list # Lister les paquets installés
```
### 📦 Commandes de Build
```bash
# Build de production
npm run build
# Analyse du bundle
npm run analyze # (si configuré)
# Build statique
npm run export # (si configuré)
```
### 🔍 Commandes de Debug
```bash
# Logs détaillés
DEBUG=* npm run dev
# Profiling
npm run dev -- --profile
# Inspection du bundle
npm run build -- --debug
```
## 📚 Documentation
### 📖 Guides Disponibles
- **[Installation](docs/INSTALLATION.md)** - Guide d'installation complet
- **[Configuration](docs/CONFIGURATION.md)** - Configuration avancée
- **[Architecture](docs/ARCHITECTURE.md)** - Architecture technique
- **[API](docs/API.md)** - Documentation des APIs
- **[Sécurité](docs/SECURITY_AUDIT.md)** - Audit de sécurité
- **[Utilisation](docs/USAGE.md)** - Guide d'utilisation
### 🔗 Ressources Externes
- [Next.js Documentation](https://nextjs.org/docs)
- [Tailwind CSS](https://tailwindcss.com/docs)
- [Radix UI](https://www.radix-ui.com/docs)
- [TypeScript Handbook](https://www.typescriptlang.org/docs/)
## 🏗️ Architecture
### 📁 Structure du Projet
```
docv/
├── app/ # Pages et routes Next.js 13+
│ ├── dashboard/ # Interface utilisateur
│ ├── login/ # Authentification
│ ├── formation/ # Module formation
│ └── contact/ # Contact
├── components/ # Composants réutilisables
│ ├── ui/ # Composants UI de base
│ └── 4nk/ # Composants spécifiques 4NK
├── lib/ # Utilitaires et logique métier
│ ├── 4nk/ # Modules 4NK
│ └── utils.ts # Fonctions utilitaires
├── public/ # Assets statiques
├── styles/ # Styles globaux
└── docs/ # Documentation
```
### 🔧 Technologies Utilisées
| Technologie | Version | Description |
|-------------|---------|-------------|
| **Next.js** | 15.2.4 | Framework React full-stack |
| **React** | 19.1.1 | Bibliothèque UI |
| **TypeScript** | 5.0+ | Typage statique |
| **Tailwind CSS** | 4.1.9 | Framework CSS |
| **Radix UI** | Latest | Composants accessibles |
| **Lucide React** | 0.454.0 | Icônes |
| **Zod** | 3.25.67 | Validation de schémas |
### 🌐 Architecture de Sécurité
```mermaid
graph TB
A[Client] --> B[Next.js App]
B --> C[Authentification Cryptographique]
C --> D[Base de Données Locale]
D --> E[Chiffrement Bout en Bout]
E --> F[Ancrage Blockchain]
G[IA Locale] --> H[Traitement OCR]
H --> I[Classification]
I --> J[Extraction de Données]
```
## 🔒 Sécurité
### 🛡️ Mesures de Sécurité Implémentées
- ✅ **Authentification sans mot de passe** - Clés cryptographiques locales
- ✅ **Chiffrement bout en bout** - Données protégées en transit et au repos
- ✅ **Aucune interface admin** - Pas d'accès privilégié exposé
- ✅ **Conformité réglementaire** - RGPD, ISO 27001, SecNumCloud
- ✅ **Audit de sécurité** - Voir [SECURITY_AUDIT.md](docs/SECURITY_AUDIT.md)
### 🔐 Bonnes Pratiques
1. **Variables d'environnement** - Jamais de secrets en dur
2. **Validation des données** - Schémas Zod pour toutes les entrées
3. **HTTPS obligatoire** - En production uniquement
4. **Audit régulier** - `npm audit` avant chaque déploiement
## 🤝 Contribution
### 🚀 Comment Contribuer
1. **Fork** le repository
2. **Créer** une branche feature (`git checkout -b feature/amazing-feature`)
3. **Commit** vos changements (`git commit -m 'Add amazing feature'`)
4. **Push** vers la branche (`git push origin feature/amazing-feature`)
5. **Ouvrir** une Pull Request
### 📝 Standards de Code
- **TypeScript** strict activé
- **ESLint** pour la qualité du code
- **Prettier** pour le formatage
- **Conventional Commits** pour les messages
### 🧪 Tests
```bash
# Avant de contribuer, assurez-vous que :
npm run lint # ✅ Pas d'erreurs ESLint
npm run type-check # ✅ Pas d'erreurs TypeScript
npm run build # ✅ Build réussi
```
## 📞 Support
### 🆘 Obtenir de l'Aide
- **📧 Email** : contact@docv.fr
- **📚 Documentation** : [docs/](docs/)
- **🐛 Issues** : [GitHub Issues](https://github.com/your-org/docv/issues)
- **💬 Discussions** : [GitHub Discussions](https://github.com/your-org/docv/discussions)
### 🏢 Entreprise
**4NK** - Pionnier du Web 5.0
- 🏢 Solutions de souveraineté
- 🔒 Sécurité de bout en bout
- 🌐 Architecture distribuée
### 📋 Checklist de Support
Avant de demander de l'aide, vérifiez :
- [ ] Version de Node.js compatible (18.0+)
- [ ] Dépendances installées (`npm install`)
- [ ] Variables d'environnement configurées
- [ ] Logs d'erreur consultés
- [ ] Documentation parcourue
---
## 📄 Licence
Ce projet est propriétaire et confidentiel. Tous droits réservés à **4NK**.
---
<div align="center">
**🛡️ DocV - Sécurisez votre entreprise avec la GED simple et souveraine**
[![4NK](https://img.shields.io/badge/By-4NK-blue.svg)](https://4nkweb.com)
[![Contact](https://img.shields.io/badge/Contact-contact@docv.fr-green.svg)](mailto:contact@docv.fr)
</div>

View File

@ -24,9 +24,9 @@ import {
ChevronRight,
Home,
} from "lucide-react"
import AuthModal from "@/components/4nk/AuthModal"
import MessageBus from "@/lib/4nk/MessageBus"
import UserStore from "@/lib/4nk/UserStore"
import { AuthModal } from "@/components/4nk/AuthModal"
import { MessageBus } from "@/lib/4nk/MessageBus"
import { UserStore } from "@/lib/4nk/UserStore"
// DebugInfo supprimé
export default function DashboardLayout({ children }: { children: React.ReactNode }) {
@ -40,7 +40,7 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
const router = useRouter()
const pathname = usePathname()
const iframeUrl = process.env.NEXT_PUBLIC_4NK_IFRAME_URL || "https://dev3.4nkweb.com"
const iframeUrl = process.env.NEXT_PUBLIC_4NK_IFRAME_URL || "https://dev.4nk.io"
const navigation = [
{ name: "Tableau de bord", href: "/dashboard", icon: LayoutDashboard },
@ -61,10 +61,10 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
if (accessToken) {
// Vérifier si on est en mode mock
// const mockMode = messageBus.isInMockMode()
// setIsMockMode(mockMode)
const mockMode = messageBus.isInMockMode()
setIsMockMode(mockMode)
if (true) {
if (mockMode) {
console.log("🎭 Dashboard en mode mock")
setIsAuthenticated(true)
setUserInfo({
@ -117,7 +117,7 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
const messageBus = MessageBus.getInstance(iframeUrl)
userStore.disconnect()
// messageBus.disableMockMode()
messageBus.disableMockMode()
// Afficher un message de confirmation avec options
setShowLogoutConfirm(true)

View File

@ -26,7 +26,7 @@ import {
HardDrive,
X,
} from "lucide-react"
import MessageBus from "@/lib/4nk/MessageBus"
import { MessageBus } from "@/lib/4nk/MessageBus"
import Link from "next/link"
export default function DashboardPage() {
@ -54,13 +54,13 @@ export default function DashboardPage() {
const [notifications, setNotifications] = useState<any[]>([])
useEffect(() => {
const iframeUrl = process.env.NEXT_PUBLIC_4NK_IFRAME_URL || "https://dev3.4nkweb.com"
const iframeUrl = process.env.NEXT_PUBLIC_4NK_IFRAME_URL || "https://dev.4nk.io"
const messageBus = MessageBus.getInstance(iframeUrl)
// const mockMode = messageBus.isInMockMode()
// setIsMockMode(mockMode)
const mockMode = messageBus.isInMockMode()
setIsMockMode(mockMode)
// Simuler le chargement des données
if (true) {
if (mockMode) {
setStats({
totalDocuments: 1247,
totalFolders: 89,

View File

@ -6,78 +6,134 @@ import { useState } from "react"
import { useRouter } from "next/navigation"
import Link from "next/link"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Button } from "@/components/ui/button"
import { Badge } from "@/components/ui/badge"
import {
Shield,
Building2,
TestTube,
ArrowLeft,
Home,
Key,
CheckCircle,
AlertTriangle,
Eye,
EyeOff,
} from "lucide-react"
import AuthModal from "@/components/4nk/AuthModal"
import MessageBus from "@/lib/4nk/MessageBus"
import UserStore from "@/lib/4nk/UserStore"
import { AuthModal } from "@/components/4nk/AuthModal"
import { MessageBus } from "@/lib/4nk/MessageBus"
import { MockService } from "@/lib/4nk/MockService"
import { UserStore } from "@/lib/4nk/UserStore"
export default function LoginPage() {
const [companyId, setCompanyId] = useState("")
const [isAuthModalOpen, setIsAuthModalOpen] = useState(false)
const [isLoading, setIsLoading] = useState(false)
const [isConnected, setIsConnected] = useState(false)
const [error, setError] = useState<string | null>(null)
const [showPairingSection, setShowPairingSection] = useState(false)
const [pairingWords, setPairingWords] = useState(["", "", "", ""])
const [pairingError, setPairingError] = useState("")
const [pairingSuccess, setPairingSuccess] = useState(false)
const router = useRouter()
const [showPairingInput, setShowPairingInput] = useState(false)
const iframeUrl = process.env.NEXT_PUBLIC_4NK_IFRAME_URL || "https://dev3.4nkweb.com"
const iframeUrl = process.env.NEXT_PUBLIC_4NK_IFRAME_URL || "https://dev.4nk.io"
// Vérifier l'état de connexion au chargement
useState(() => {
const userStore = UserStore.getInstance()
setIsConnected(userStore.isConnected())
})
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
const handleLogin = () => {
setIsAuthModalOpen(true)
setError(null)
if (!companyId.trim()) {
return
}
const handleAuthSuccess = async () => {
setIsAuthModalOpen(false)
setIsConnected(true)
setIsLoading(true)
try {
// Récupérer l'ID d'appairage après connexion
// Si l'identifiant est "1234", activer le mode mock directement
if (companyId === "1234") {
console.log("🎭 Activation du mode mock avec l'identifiant:", companyId)
const messageBus = MessageBus.getInstance(iframeUrl)
await messageBus.isReady()
const pairingId = await messageBus.getUserPairingId()
const mockService = MockService.getInstance()
const userStore = UserStore.getInstance()
console.log("✅ Authentification 4NK réussie, ID d'appairage:", pairingId)
// Activer le mode mock
messageBus.enableMockMode()
// Redirection vers le dashboard
router.push("/dashboard")
} catch (err) {
console.error("Erreur lors de la récupération de l'ID d'appairage:", err)
// Redirection quand même vers le dashboard
// Authentification mock
const authResult = await mockService.mockAuthentication(companyId)
if (!authResult) {
throw new Error("Échec de l'authentification de démonstration")
}
// Simuler la récupération des tokens
const tokens = await mockService.mockRequestLink()
userStore.connect(tokens.accessToken, tokens.refreshToken)
// Simuler la récupération de l'ID d'appairage
const pairingId = await mockService.mockGetUserPairingId()
userStore.pair(pairingId)
console.log("✅ Mode mock activé avec succès")
// Redirection directe vers le dashboard
router.push("/dashboard")
} else {
// Mode normal - ouvrir la modal d'authentification 4NK
setIsAuthModalOpen(true)
}
} catch (error) {
console.error("Erreur lors de l'activation du mode mock:", error)
// En cas d'erreur, ouvrir quand même la modal d'authentification
setIsAuthModalOpen(true)
} finally {
setIsLoading(false)
}
}
const handleAuthError = (errorMessage: string) => {
setError(errorMessage)
const handleAuthSuccess = () => {
setIsAuthModalOpen(false)
router.push("/dashboard")
}
// Si déjà connecté, rediriger vers le dashboard
if (isConnected) {
const handlePairingSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setPairingError("")
// Vérifier que tous les mots sont remplis
if (pairingWords.some((word) => !word.trim())) {
setPairingError("Veuillez saisir les 4 mots de pairing")
return
}
// Simuler la vérification des mots de pairing
const validWords = ["alpha", "bravo", "charlie", "delta"]
const isValid = pairingWords.every((word, index) => word.toLowerCase().trim() === validWords[index])
if (isValid) {
setPairingSuccess(true)
setTimeout(() => {
// Simuler l'ajout de l'appareil et la connexion
const userStore = UserStore.getInstance()
const mockService = MockService.getInstance()
// Simuler des tokens pour le pairing
userStore.connect("paired_access_token", "paired_refresh_token")
userStore.pair("paired_device_id")
router.push("/dashboard")
return (
<div className="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100 flex items-center justify-center p-4">
<Card className="w-full max-w-md">
<CardContent className="text-center py-8">
<CheckCircle className="h-12 w-12 mx-auto text-green-600 mb-4" />
<h2 className="text-xl font-semibold mb-2">Déjà connecté</h2>
<p className="text-gray-600">Redirection vers le dashboard...</p>
</CardContent>
</Card>
</div>
)
}, 2000)
} else {
setPairingError("Mots de pairing incorrects. Vérifiez les mots saisis sur votre autre appareil.")
}
}
const handlePairingWordChange = (index: number, value: string) => {
const newWords = [...pairingWords]
newWords[index] = value
setPairingWords(newWords)
setPairingError("")
}
return (
@ -103,57 +159,160 @@ export default function LoginPage() {
<p className="text-gray-600">Gestion électronique de documents sécurisée</p>
</div>
{/* Carte de connexion 4NK */}
<Card>
<CardHeader>
<CardTitle className="text-center">
<Shield className="h-8 w-8 mx-auto mb-4 text-blue-600" />
Connexion sécurisée 4NK
</CardTitle>
<CardDescription className="text-center">
Authentification cryptographique sans mot de passe
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{/* Description de la connexion 4NK */}
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
<h3 className="font-semibold text-blue-900 mb-2">🔐 Authentification 4NK</h3>
<ul className="text-sm text-blue-800 space-y-1">
<li> Aucun mot de passe requis</li>
<li> Identité cryptographique sécurisée</li>
<li> Chiffrement bout en bout</li>
<li> Protection par blockchain</li>
</ul>
{/* Navigation entre connexion et pairing */}
<div className="flex space-x-1 bg-gray-100 p-1 rounded-lg">
<button
onClick={() => setShowPairingSection(false)}
className={`flex-1 py-2 px-4 text-sm font-medium rounded-md transition-colors ${
!showPairingSection ? "bg-white text-gray-900 shadow-sm" : "text-gray-600 hover:text-gray-900"
}`}
>
<Building2 className="h-4 w-4 inline mr-2" />
Connexion
</button>
<button
onClick={() => setShowPairingSection(true)}
className={`flex-1 py-2 px-4 text-sm font-medium rounded-md transition-colors ${
showPairingSection ? "bg-white text-gray-900 shadow-sm" : "text-gray-600 hover:text-gray-900"
}`}
>
<Key className="h-4 w-4 inline mr-2" />
Pairing
</button>
</div>
{/* Affichage des erreurs */}
{error && (
<div className="bg-red-50 border border-red-200 rounded-lg p-4">
<p className="text-red-700 font-medium">Erreur de connexion :</p>
<p className="text-red-600 text-sm">{error}</p>
{!showPairingSection ? (
/* Carte de connexion */
<Card>
<CardHeader>
<CardTitle className="flex items-center">
<Building2 className="h-5 w-5 mr-2 text-blue-600" />
Identification d'entreprise
</CardTitle>
<CardDescription>Connectez-vous avec votre identifiant unique sécurisé par 4NK</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="companyId">Votre identifiant unique</Label>
<Input
id="companyId"
type="text"
placeholder="Saisissez votre identifiant d'entreprise"
value={companyId}
onChange={(e) => setCompanyId(e.target.value)}
required
className="w-full"
/>
</div>
{/* Info mode démonstration */}
<div className="bg-green-50 border border-green-200 rounded-lg p-3">
<div className="flex items-start space-x-2">
<TestTube className="h-4 w-4 text-green-600 mt-0.5 flex-shrink-0" />
<div className="text-xs text-green-700">
<p className="font-medium mb-1">Mode démonstration</p>
<p>
Utilisez l'identifiant <strong>"1234"</strong> pour accéder directement aux écrans de
démonstration avec des données simulées.
</p>
</div>
</div>
</div>
<Button type="submit" className="w-full" disabled={isLoading}>
{isLoading ? "Connexion en cours..." : "Se connecter"}
</Button>
</form>
</CardContent>
</Card>
) : (
/* Carte de pairing */
<Card>
<CardHeader>
<CardTitle className="flex items-center">
<Key className="h-5 w-5 mr-2 text-blue-600" />
Pairing d'appareil
</CardTitle>
<CardDescription>Ajoutez cet appareil à votre compte existant</CardDescription>
</CardHeader>
<CardContent>
{!pairingSuccess ? (
<form onSubmit={handlePairingSubmit} className="space-y-4">
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label>Mots de pairing temporaires</Label>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => setShowPairingInput(!showPairingInput)}
className="text-blue-700 border-blue-300"
>
{showPairingInput ? <EyeOff className="h-4 w-4 mr-1" /> : <Eye className="h-4 w-4 mr-1" />}
{showPairingInput ? "Masquer" : "Afficher"}
</Button>
</div>
<p className="text-sm text-gray-600">Saisissez les 4 mots affichés sur votre autre appareil</p>
<div className="grid grid-cols-2 gap-2">
{pairingWords.map((word, index) => (
<Input
key={index}
type={showPairingInput ? "text" : "password"}
placeholder={`Mot ${index + 1}`}
value={word}
onChange={(e) => handlePairingWordChange(index, e.target.value)}
className="text-center font-mono select-none"
style={{ userSelect: "none", WebkitUserSelect: "none" }}
onContextMenu={(e) => e.preventDefault()}
onCopy={(e) => e.preventDefault()}
onCut={(e) => e.preventDefault()}
onPaste={(e) => e.preventDefault()}
autoComplete="off"
spellCheck={false}
required
/>
))}
</div>
</div>
{pairingError && (
<div className="bg-red-50 border border-red-200 rounded-lg p-3">
<div className="flex items-start space-x-2">
<AlertTriangle className="h-4 w-4 text-red-600 mt-0.5 flex-shrink-0" />
<p className="text-sm text-red-700">{pairingError}</p>
</div>
</div>
)}
{/* Bouton de connexion */}
<Button
onClick={handleLogin}
className="w-full"
size="lg"
disabled={isLoading}
>
<Shield className="h-5 w-5 mr-2" />
{isLoading ? "Connexion en cours..." : "Se connecter avec 4NK"}
</Button>
{/* Informations sur l'iframe */}
<div className="bg-gray-50 border border-gray-200 rounded-lg p-3">
<p className="text-xs text-gray-600 text-center">
<strong>URL d'authentification :</strong><br />
{iframeUrl}
</p>
<div className="bg-blue-50 border border-blue-200 rounded-lg p-3">
<div className="text-xs text-blue-700">
<p className="font-medium mb-1">Instructions :</p>
<ol className="space-y-1">
<li>1. Ouvrez DocV sur votre appareil principal</li>
<li>2. Allez dans Paramètres Sécurité</li>
<li>3. Cliquez sur "Ajouter un appareil"</li>
<li>4. Saisissez les 4 mots affichés ici</li>
</ol>
</div>
</div>
<Button type="submit" className="w-full">
<Key className="h-4 w-4 mr-2" />
Appairer cet appareil
</Button>
</form>
) : (
<div className="text-center py-6">
<CheckCircle className="h-12 w-12 mx-auto text-green-600 mb-4" />
<h3 className="text-lg font-semibold text-gray-900 mb-2">Pairing réussi !</h3>
<p className="text-gray-600 mb-4">Cet appareil a é ajouté à votre compte avec succès.</p>
<div className="animate-pulse text-blue-600">Redirection vers le dashboard...</div>
</div>
)}
</CardContent>
</Card>
)}
{/* Badges de sécurité */}
<div className="flex flex-wrap justify-center gap-2">

View File

@ -1,101 +1,330 @@
import { useState, useEffect, memo } from 'react';
import Iframe from './Iframe';
import MessageBus from '@/lib/4nk/MessageBus';
import Loader from '@/lib/4nk/Loader';
import Modal from '../modal/Modal';
"use client"
import { useState, useEffect, memo } from "react"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Shield, CheckCircle, Loader2, AlertCircle, RefreshCw } from "lucide-react"
import { Iframe } from "./Iframe"
import { MessageBus } from "@/lib/4nk/MessageBus"
import { IframeReference } from "@/lib/4nk/IframeReference"
interface AuthModalProps {
isOpen: boolean;
onConnect: () => void;
onClose: () => void;
iframeUrl: string;
isOpen: boolean
onConnect: () => void
onClose: () => void
iframeUrl: string
}
function AuthModal({ isOpen, onConnect, onClose, iframeUrl }: AuthModalProps) {
const [isIframeReady, setIsIframeReady] = useState(false);
const [showIframe, setShowIframe] = useState(false);
const [authSuccess, setAuthSuccess] = useState(false);
export const AuthModal = memo(function AuthModal({ isOpen, onConnect, onClose, iframeUrl }: AuthModalProps) {
const [isIframeReady, setIsIframeReady] = useState(false)
const [showIframe, setShowIframe] = useState(false)
const [authSuccess, setAuthSuccess] = useState(false)
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [loadingStep, setLoadingStep] = useState("")
const [retryCount, setRetryCount] = useState(0)
const [iframeLoaded, setIframeLoaded] = useState(false)
useEffect(() => {
MessageBus.getInstance(iframeUrl).isReady().then(() => {
setIsIframeReady(true);
});
}, [iframeUrl]);
const maxRetries = 3
useEffect(() => {
if (!isOpen) {
setShowIframe(false);
setIsIframeReady(false);
setAuthSuccess(false);
// Reset des états à la fermeture
setIsIframeReady(false)
setShowIframe(false)
setAuthSuccess(false)
setIsLoading(false)
setError(null)
setLoadingStep("")
setRetryCount(0)
setIframeLoaded(false)
return
}
}, [isOpen]);
useEffect(() => {
if (isIframeReady && !showIframe) {
setShowIframe(true);
initAuth()
}, [isOpen, iframeUrl, retryCount])
MessageBus.getInstance(iframeUrl).requestLink().then(() => {
setAuthSuccess(true);
const initAuth = async () => {
try {
setIsLoading(true)
setError(null)
setLoadingStep("Initialisation...")
setTimeout(() => onConnect(), 500);
}).catch((_error: string) => {
setShowIframe(false);
setIsIframeReady(false);
setAuthSuccess(false);
console.log("🔗 Initialisation authentification 4NK avec:", iframeUrl)
console.log("🔄 Tentative:", retryCount + 1, "/", maxRetries + 1)
onClose();
});
// Étape 1: Attendre que l'iframe soit disponible dans le DOM
setLoadingStep("Chargement de l'iframe...")
let attempts = 0
const maxAttempts = 40 // 20 secondes
while (attempts < maxAttempts) {
const iframe = IframeReference.getIframe()
if (iframe && iframe.contentWindow) {
console.log("✅ Iframe disponible après", attempts * 500, "ms")
break
}
}, [isIframeReady, showIframe]);
await new Promise((resolve) => setTimeout(resolve, 500))
attempts++
}
if (attempts >= maxAttempts) {
throw new Error("Iframe 4NK non disponible dans le DOM après 20 secondes")
}
// Étape 2: Attendre que l'iframe soit complètement chargée
setLoadingStep("Attente du chargement complet...")
await waitForIframeLoad()
setShowIframe(true)
// Étape 3: Attendre le message LISTENING de l'iframe
setLoadingStep("Attente du signal LISTENING...")
const messageBus = MessageBus.getInstance(iframeUrl)
console.log("⏳ Attente du message LISTENING de l'iframe...")
await messageBus.isReady()
console.log("✅ Iframe prête et en écoute")
setIsIframeReady(true)
// Étape 4: Demander l'authentification (REQUEST_LINK)
setLoadingStep("Demande d'authentification...")
console.log("🔐 Envoi REQUEST_LINK...")
await messageBus.requestLink()
console.log("✅ LINK_ACCEPTED reçu, tokens stockés")
// Étape 5: Récupérer l'ID d'appairage
setLoadingStep("Récupération de l'identité...")
console.log("🆔 Récupération de l'ID d'appairage...")
await messageBus.getUserPairingId()
console.log("✅ ID d'appairage récupéré")
setAuthSuccess(true)
// Délai avant de déclencher onConnect
setTimeout(() => {
onConnect()
}, 500)
} catch (err) {
console.error("❌ Authentication error:", err)
const errorMessage = err instanceof Error ? err.message : "Erreur d'authentification"
// Messages d'erreur plus spécifiques selon le protocole 4NK
if (errorMessage.includes("LINK_ACCEPTED")) {
setError("Erreur d'authentification : réponse inattendue du serveur 4NK")
} else if (errorMessage.includes("Tokens manquants")) {
setError("Erreur : les tokens d'authentification n'ont pas été reçus")
} else if (errorMessage.includes("LISTENING")) {
setError("L'iframe 4NK n'est pas en écoute. Vérifiez l'URL de l'iframe.")
} else if (errorMessage.includes("Timeout")) {
setError("Timeout : L'iframe 4NK ne répond pas. Vérifiez votre connexion.")
} else if (errorMessage.includes("origin")) {
setError("Erreur de configuration : les domaines ne correspondent pas")
} else {
setError(errorMessage)
}
setIsIframeReady(false)
setShowIframe(false)
} finally {
setIsLoading(false)
setLoadingStep("")
}
}
const waitForIframeLoad = (): Promise<void> => {
return new Promise((resolve, reject) => {
const iframe = IframeReference.getIframe()
if (!iframe) {
reject(new Error("Iframe not found"))
return
}
// Si l'iframe est déjà chargée
if (iframeLoaded) {
resolve()
return
}
const timeout = setTimeout(() => {
cleanup()
reject(new Error("Timeout: L'iframe n'a pas fini de se charger"))
}, 30000) // 30 secondes pour le chargement
const cleanup = () => {
clearTimeout(timeout)
iframe.removeEventListener("load", onLoad)
iframe.removeEventListener("error", onError)
}
const onLoad = () => {
console.log("✅ Iframe loaded successfully")
setIframeLoaded(true)
cleanup()
// Attendre un peu plus pour que le contenu soit prêt
setTimeout(resolve, 2000)
}
const onError = () => {
console.error("❌ Iframe failed to load")
cleanup()
reject(new Error("Erreur de chargement de l'iframe"))
}
iframe.addEventListener("load", onLoad)
iframe.addEventListener("error", onError)
// Si l'iframe semble déjà chargée
if (iframe.contentDocument?.readyState === "complete") {
onLoad()
}
})
}
const handleRetry = () => {
if (retryCount < maxRetries) {
setRetryCount((prev) => prev + 1)
setError(null)
} else {
setError("Nombre maximum de tentatives atteint. Veuillez vérifier votre connexion et réessayer plus tard.")
}
}
const handleForceRetry = () => {
setRetryCount(0)
setError(null)
setIframeLoaded(false)
// Forcer le rechargement de l'iframe
const iframe = IframeReference.getIframe()
if (iframe) {
iframe.src = iframe.src
}
}
if (!isOpen) return null
return (
<Modal
isOpen={isOpen}
onClose={onClose}
title='Authentification 4nk'
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<Card className="w-full max-w-md">
<CardHeader className="text-center">
<div className="flex items-center justify-center mb-4">
<Shield className="h-12 w-12 text-blue-600" />
</div>
<CardTitle className="text-2xl">Authentification 4NK</CardTitle>
<CardDescription>Connexion sécurisée avec votre identité cryptographique</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{error && (
<div className="bg-red-50 border border-red-200 rounded-lg p-4">
<div className="flex items-start space-x-2">
<AlertCircle className="h-5 w-5 text-red-600 mt-0.5 flex-shrink-0" />
<div className="flex-1">
<p className="text-red-700 text-sm font-medium mb-2">Erreur de connexion</p>
<p className="text-red-600 text-xs mb-3">{error}</p>
<div className="flex flex-col gap-2">
{retryCount < maxRetries && (
<Button
variant="outline"
size="sm"
onClick={handleRetry}
className="bg-transparent text-red-700 border-red-300 hover:bg-red-50"
>
{!isIframeReady && (
<div style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
height: '400px',
gap: 16
}}>
<Loader width={40} />
<div style={{ fontWeight: 600, fontSize: 18 }}>Chargement de l'authentification...</div>
<RefreshCw className="h-4 w-4 mr-2" />
Réessayer ({retryCount + 1}/{maxRetries + 1})
</Button>
)}
<Button
variant="outline"
size="sm"
onClick={handleForceRetry}
className="bg-transparent text-red-700 border-red-300 hover:bg-red-50"
>
<RefreshCw className="h-4 w-4 mr-2" />
Forcer le rechargement
</Button>
</div>
</div>
</div>
</div>
)}
{authSuccess ? (
<div style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
height: '400px',
gap: 20
}}>
<div style={{ fontWeight: 600, fontSize: 18, color: '#43a047' }}>
Authentification réussie !
</div>
</div>
) : (
<div style={{
display: showIframe ? 'flex' : 'none',
justifyContent: 'center',
alignItems: 'center',
width: '100%'
}}>
<Iframe
iframeUrl={iframeUrl}
showIframe={showIframe}
{isLoading && !authSuccess && (
<div className="text-center py-8">
<Loader2 className="h-8 w-8 animate-spin mx-auto mb-4 text-blue-600" />
<p className="text-gray-600 font-medium">{loadingStep}</p>
{loadingStep && <p className="text-gray-500 text-sm mt-2">Protocole 4NK en cours...</p>}
{/* Barre de progression visuelle */}
<div className="mt-4 w-full bg-gray-200 rounded-full h-2">
<div
className="bg-blue-600 h-2 rounded-full transition-all duration-500"
style={{
width:
loadingStep === "Initialisation..."
? "15%"
: loadingStep === "Chargement de l'iframe..."
? "30%"
: loadingStep === "Attente du chargement complet..."
? "45%"
: loadingStep === "Attente du signal LISTENING..."
? "60%"
: loadingStep === "Demande d'authentification..."
? "80%"
: loadingStep === "Récupération de l'identité..."
? "95%"
: "0%",
}}
/>
</div>
</div>
)}
</Modal>
);
}
AuthModal.displayName = 'AuthModal';
export default memo(AuthModal);
{authSuccess && (
<div className="text-center py-8">
<CheckCircle className="h-12 w-12 text-green-600 mx-auto mb-4" />
<p className="text-green-700 font-semibold">Authentification réussie !</p>
<p className="text-gray-600 text-sm mt-2">Tokens stockés Redirection en cours...</p>
</div>
)}
{showIframe && !authSuccess && !error && (
<div className="border rounded-lg overflow-hidden">
<div className="bg-blue-50 p-2 text-center">
<p className="text-blue-700 text-sm">Interface d'authentification 4NK</p>
<p className="text-blue-600 text-xs">En attente de LISTENING REQUEST_LINK LINK_ACCEPTED</p>
</div>
<Iframe iframeUrl={iframeUrl} showIframe={true} />
</div>
)}
{/* Informations de debug */}
{(error || isLoading) && (
<div className="bg-gray-50 p-3 rounded-lg text-xs">
<p>
<strong>URL:</strong> {iframeUrl}
</p>
<p>
<strong>Tentative:</strong> {retryCount + 1}/{maxRetries + 1}
</p>
<p>
<strong>Iframe chargée:</strong> {iframeLoaded ? "Oui" : "Non"}
</p>
<p>
<strong>Protocole:</strong> LISTENING REQUEST_LINK LINK_ACCEPTED
</p>
</div>
)}
<div className="text-center">
<Button variant="outline" onClick={onClose}>
Annuler
</Button>
</div>
</CardContent>
</Card>
</div>
)
})

View File

@ -1,32 +1,38 @@
import { useRef, useEffect, memo } from 'react';
import IframeReference from '@/lib/4nk/IframeReference';
"use client"
function Iframe({ iframeUrl, showIframe = false }: { iframeUrl: string; showIframe?: boolean }) {
const iframeRef = useRef<HTMLIFrameElement>(null);
import { useRef, useEffect, memo } from "react"
import { IframeReference } from "@/lib/4nk/IframeReference"
interface IframeProps {
iframeUrl: string
showIframe?: boolean
}
export const Iframe = memo(function Iframe({ iframeUrl, showIframe = false }: IframeProps) {
const iframeRef = useRef<HTMLIFrameElement>(null)
useEffect(() => {
if (iframeRef.current) {
IframeReference.setIframe(iframeRef.current);
IframeReference.setIframe(iframeRef.current)
}
return () => {
IframeReference.setIframe(null);
};
}, [iframeRef.current]);
IframeReference.setIframe(null)
}
}, [])
return (
<iframe
ref={iframeRef}
src={iframeUrl}
style={{
display: showIframe ? 'block' : 'none',
width: '400px',
height: '400px',
border: 'none',
overflow: 'hidden'
width: showIframe ? "100%" : "0",
height: showIframe ? "400px" : "0",
border: "none",
display: showIframe ? "block" : "none",
}}
sandbox="allow-scripts allow-same-origin allow-forms"
title="4NK Authentication"
/>
);
}
Iframe.displayName = 'Iframe';
export default memo(Iframe);
)
})

View File

@ -1,163 +0,0 @@
.modal-overlay {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
background: rgba(35, 36, 42, 0.82);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
animation: modal-fadein 0.33s cubic-bezier(.4, 0, .2, 1);
backdrop-filter: blur(3.5px);
-webkit-backdrop-filter: blur(3.5px);
}
@keyframes modal-fadein {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.modal-container {
background: #23242a;
border-radius: 18px;
min-width: 340px;
max-width: 95vw;
min-height: 0;
padding: 0 0 24px 0;
position: relative;
box-shadow: 0 12px 48px 0 rgba(0, 0, 0, 0.34), 0 2px 12px 0 rgba(30, 34, 44, 0.10);
overflow: hidden;
animation: modal-popin 0.34s cubic-bezier(.4, 0, .2, 1);
transition: box-shadow 0.2s, opacity 0.25s cubic-bezier(.4, 0, .2, 1);
}
.modal-container.modal-closing {
opacity: 0;
transform: translateY(32px) scale(0.97);
pointer-events: none;
}
@keyframes modal-popin {
from {
opacity: 0;
transform: translateY(32px) scale(0.97);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
.modal-header {
background: linear-gradient(90deg, #23242a 85%, #23242aEE 100%);
color: #fff;
padding: 22px 30px 14px 30px;
border-radius: 18px 18px 0 0;
box-shadow: 0 2px 12px 0 rgba(30, 34, 44, 0.06);
position: relative;
display: flex;
align-items: center;
min-height: 52px;
}
.modal-header h2 {
margin: 0;
font-size: 1.3rem;
font-weight: 600;
letter-spacing: 0.01em;
color: #fff;
}
.modal-close {
position: absolute;
top: 10px;
right: 16px;
background: transparent;
border: none;
font-size: 2rem;
color: #e3e4e8;
width: 36px;
height: 36px;
min-width: 36px;
min-height: 36px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: background 0.18s, color 0.18s;
z-index: 2;
border-radius: 6px;
padding: 0;
}
.modal-close svg {
display: block;
width: 24px;
height: 24px;
background: none;
pointer-events: none;
}
.modal-close:hover,
.modal-close:focus {
background: rgba(255, 255, 255, 0.10);
color: #fff;
}
.modal-close:active {
background: rgba(67, 160, 71, 0.13);
}
.modal-close:active {
background: rgba(67, 160, 71, 0.13);
}
.modal-body {
padding: 28px 28px 0 28px;
max-height: 70vh;
overflow-y: auto;
color: #e3e4e8;
font-size: 1rem;
}
@media (max-width: 600px) {
.modal-container {
min-width: 0;
width: 98vw;
padding: 0 0 12px 0;
border-radius: 12px;
}
.modal-header {
padding: 16px 10px 10px 14px;
border-radius: 12px 12px 0 0;
}
.modal-body {
padding: 14px 8px 0 8px;
}
.modal-close {
top: 6px;
right: 6px;
width: 30px;
height: 30px;
font-size: 1.2rem;
}
}
.modal-body {
width: 100%;
}

View File

@ -1,38 +0,0 @@
import React, { memo } from 'react';
import './Modal.css';
interface ModalProps {
isOpen: boolean;
onClose: () => void;
title?: string;
children: React.ReactNode;
}
function Modal({ isOpen, onClose, title, children }: ModalProps) {
if (!isOpen) {
return null;
}
return (
<div className="modal-overlay modal-fadein">
<div className="modal-container modal-popin">
<button className="close-button modal-close" onClick={onClose} aria-label="Fermer">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<path d="M6 6L18 18M18 6L6 18" stroke="#fff" strokeWidth="2.4" strokeLinecap="round" filter="url(#shadow)" />
<defs>
<filter id="shadow" x="-2" y="-2" width="28" height="28" filterUnits="userSpaceOnUse">
<feDropShadow dx="0" dy="0" stdDeviation="1.2" floodColor="#23242a" />
</filter>
</defs>
</svg>
</button>
{title && <div className="modal-header modal-header"><h2>{title}</h2></div>}
<div className="modal-body modal-body">
{children}
</div>
</div>
</div>
);
}
Modal.displayName = 'Modal';
export default memo(Modal);

View File

@ -1,33 +1,47 @@
export default class EventBus {
private static instance: EventBus;
private listeners: Record<string, Array<(...args: any[]) => void>> = {};
/**
* EventBus - Bus d'événements intra-onglet pour la communication interne
* Pattern Singleton avec pub/sub
*/
export class EventBus {
private static instance: EventBus | null = null
private listeners: Map<string, Array<(...args: any[]) => void>> = new Map()
private constructor() { }
private constructor() {}
public static getInstance(): EventBus {
static getInstance(): EventBus {
if (!EventBus.instance) {
EventBus.instance = new EventBus();
EventBus.instance = new EventBus()
}
return EventBus.instance;
return EventBus.instance
}
public on(event: string, callback: (...args: any[]) => void): () => void {
if (!this.listeners[event]) {
this.listeners[event] = [];
on(event: string, callback: (...args: any[]) => void): () => void {
if (!this.listeners.has(event)) {
this.listeners.set(event, [])
}
this.listeners[event].push(callback);
const eventListeners = this.listeners.get(event)!
eventListeners.push(callback)
// Retourne une fonction d'unsubscribe
return () => {
if (this.listeners[event]) {
this.listeners[event] = this.listeners[event].filter(cb => cb !== callback);
const index = eventListeners.indexOf(callback)
if (index > -1) {
eventListeners.splice(index, 1)
}
}
};
}
public emit(event: string, ...args: any[]): void {
if (this.listeners[event]) {
this.listeners[event].forEach(callback => {
callback(...args);
});
emit(event: string, ...args: any[]): void {
const eventListeners = this.listeners.get(event)
if (eventListeners) {
eventListeners.forEach((callback) => {
try {
callback(...args)
} catch (error) {
console.error(`Error in event listener for ${event}:`, error)
}
})
}
}
}

View File

@ -1,13 +1,15 @@
export default class IframeReference {
private static iframe: HTMLIFrameElement | null = null;
/**
* IframeReference - Référence globale pour l'iframe 4NK
* Permet aux autres services d'accéder à l'iframe pour postMessage
*/
export class IframeReference {
private static iframe: HTMLIFrameElement | null = null
private constructor() { }
public static setIframe(iframe: HTMLIFrameElement | null): void {
this.iframe = iframe;
static setIframe(iframe: HTMLIFrameElement | null): void {
IframeReference.iframe = iframe
}
public static getIframe(): HTMLIFrameElement | null {
return this.iframe;
static getIframe(): HTMLIFrameElement | null {
return IframeReference.iframe
}
}

View File

@ -1,23 +0,0 @@
import { memo } from 'react';
function Loader({ width = 40 }: { width?: number }) {
return (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', width: width }}>
<div
className='loader'
style={{
width,
height: width,
border: '4px solid #eee',
borderTop: '4px solid #333',
borderRadius: '50%',
animation: 'spin 1s linear infinite',
}}
/>
<style>{`@keyframes spin { 0% { transform: rotate(0deg);} 100% { transform: rotate(360deg);} }`}</style>
</div>
);
}
Loader.displayName = 'Loader';
export default memo(Loader);

File diff suppressed because it is too large Load Diff

363
lib/4nk/MockService.ts Normal file
View File

@ -0,0 +1,363 @@
/**
* MockService - Service de simulation 4NK pour les tests et développement
* S'active avec l'identifiant d'entreprise "1234"
*/
export class MockService {
private static instance: MockService | null = null
private isAuthenticated = false
private mockTokens = {
accessToken: "mock_access_token_1234567890",
refreshToken: "mock_refresh_token_0987654321",
}
private mockUserPairingId = "mock_pairing_id_abcdef123456"
private constructor() {}
static getInstance(): MockService {
if (!MockService.instance) {
MockService.instance = new MockService()
}
return MockService.instance
}
// Données mockées
private mockProcesses = [
{
id: "process_001",
type: "document",
name: "Contrat de travail - Jean Dupont",
status: "validated",
created_at: "2024-01-15T10:30:00Z",
updated_at: "2024-01-15T14:20:00Z",
owner: "user_001",
states: [
{
state_id: "state_001_001",
timestamp: "2024-01-15T10:30:00Z",
status: "created",
},
{
state_id: "state_001_002",
timestamp: "2024-01-15T14:20:00Z",
status: "validated",
},
],
},
{
id: "process_002",
type: "folder",
name: "Dossier RH - Marie Martin",
status: "pending",
created_at: "2024-01-16T09:15:00Z",
updated_at: "2024-01-16T16:45:00Z",
owner: "user_002",
states: [
{
state_id: "state_002_001",
timestamp: "2024-01-16T09:15:00Z",
status: "created",
},
],
},
{
id: "process_003",
type: "document",
name: "Facture SARL TechCorp",
status: "signed",
created_at: "2024-01-17T11:00:00Z",
updated_at: "2024-01-17T17:30:00Z",
owner: "user_001",
states: [
{
state_id: "state_003_001",
timestamp: "2024-01-17T11:00:00Z",
status: "created",
},
{
state_id: "state_003_002",
timestamp: "2024-01-17T17:30:00Z",
status: "signed",
},
],
},
{
id: "process_004",
type: "document",
name: "Rapport d'audit 2024",
status: "draft",
created_at: "2024-01-18T08:45:00Z",
updated_at: "2024-01-18T08:45:00Z",
owner: "user_003",
states: [
{
state_id: "state_004_001",
timestamp: "2024-01-18T08:45:00Z",
status: "draft",
},
],
},
{
id: "process_005",
type: "folder",
name: "Dossier Comptabilité Q1",
status: "validated",
created_at: "2024-01-10T14:20:00Z",
updated_at: "2024-01-19T10:15:00Z",
owner: "user_001",
states: [
{
state_id: "state_005_001",
timestamp: "2024-01-10T14:20:00Z",
status: "created",
},
{
state_id: "state_005_002",
timestamp: "2024-01-19T10:15:00Z",
status: "validated",
},
],
},
]
private mockMyProcesses = ["process_001", "process_003", "process_005"]
private mockProcessData = {
process_001: {
employee_name: "Jean Dupont",
employee_email: "jean.dupont@example.com",
position: "Développeur Senior",
salary: "45000",
start_date: "2024-02-01",
contract_type: "CDI",
},
process_002: {
employee_name: "Marie Martin",
employee_email: "marie.martin@example.com",
documents_count: 12,
last_review: "2023-12-15",
department: "Ressources Humaines",
},
process_003: {
client_name: "SARL TechCorp",
amount: "15750.00",
invoice_number: "INV-2024-001",
due_date: "2024-02-15",
status: "paid",
},
process_004: {
audit_year: "2024",
auditor: "Cabinet Expertise",
scope: "Audit financier complet",
completion: "25%",
},
process_005: {
quarter: "Q1 2024",
documents_count: 45,
total_amount: "125000.00",
status: "closed",
},
}
// Simulation de l'authentification
async mockAuthentication(companyId: string): Promise<boolean> {
console.log("🎭 Mock authentication for company:", companyId)
// Simuler un délai d'authentification
await new Promise((resolve) => setTimeout(resolve, 1500))
if (companyId === "1234") {
this.isAuthenticated = true
console.log("✅ Mock authentication successful")
return true
}
console.log("❌ Mock authentication failed - invalid company ID")
return false
}
// Simulation des méthodes 4NK
async mockRequestLink(): Promise<{ accessToken: string; refreshToken: string }> {
console.log("🎭 Mock REQUEST_LINK")
await new Promise((resolve) => setTimeout(resolve, 1000))
if (!this.isAuthenticated) {
throw new Error("Not authenticated")
}
return this.mockTokens
}
async mockGetUserPairingId(): Promise<string> {
console.log("🎭 Mock GET_PAIRING_ID")
await new Promise((resolve) => setTimeout(resolve, 500))
if (!this.isAuthenticated) {
throw new Error("Not authenticated")
}
return this.mockUserPairingId
}
async mockGetProcesses(): Promise<any[]> {
console.log("🎭 Mock GET_PROCESSES")
await new Promise((resolve) => setTimeout(resolve, 800))
if (!this.isAuthenticated) {
throw new Error("Not authenticated")
}
return this.mockProcesses
}
async mockGetMyProcesses(): Promise<string[]> {
console.log("🎭 Mock GET_MY_PROCESSES")
await new Promise((resolve) => setTimeout(resolve, 600))
if (!this.isAuthenticated) {
throw new Error("Not authenticated")
}
return this.mockMyProcesses
}
async mockGetData(processId: string, stateId: string): Promise<Record<string, any>> {
console.log("🎭 Mock RETRIEVE_DATA for process:", processId)
await new Promise((resolve) => setTimeout(resolve, 700))
if (!this.isAuthenticated) {
throw new Error("Not authenticated")
}
return this.mockProcessData[processId as keyof typeof this.mockProcessData] || {}
}
async mockCreateProcess(processData: any, privateFields: string[], roles: any): Promise<any> {
console.log("🎭 Mock CREATE_PROCESS")
await new Promise((resolve) => setTimeout(resolve, 1200))
if (!this.isAuthenticated) {
throw new Error("Not authenticated")
}
const newProcessId = `process_${Date.now()}`
const newProcess = {
id: newProcessId,
type: processData.type || "document",
name: processData.name || "Nouveau process",
status: "created",
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
owner: "current_user",
states: [
{
state_id: `state_${newProcessId}_001`,
timestamp: new Date().toISOString(),
status: "created",
},
],
}
// Ajouter aux processes mockés
this.mockProcesses.push(newProcess)
this.mockMyProcesses.push(newProcessId)
this.mockProcessData[newProcessId as keyof typeof this.mockProcessData] = processData
return {
processId: newProcessId,
process: newProcess,
processData,
}
}
async mockNotifyProcessUpdate(processId: string, stateId: string): Promise<void> {
console.log("🎭 Mock NOTIFY_UPDATE for process:", processId)
await new Promise((resolve) => setTimeout(resolve, 500))
if (!this.isAuthenticated) {
throw new Error("Not authenticated")
}
// Simuler la notification
console.log("✅ Mock notification sent")
}
async mockValidateState(processId: string, stateId: string): Promise<any> {
console.log("🎭 Mock VALIDATE_STATE for process:", processId)
await new Promise((resolve) => setTimeout(resolve, 800))
if (!this.isAuthenticated) {
throw new Error("Not authenticated")
}
// Trouver le process et mettre à jour son statut
const process = this.mockProcesses.find((p) => p.id === processId)
if (process) {
process.status = "validated"
process.updated_at = new Date().toISOString()
process.states.push({
state_id: `${stateId}_validated`,
timestamp: new Date().toISOString(),
status: "validated",
})
}
return process
}
async mockValidateToken(): Promise<boolean> {
console.log("🎭 Mock VALIDATE_TOKEN")
await new Promise((resolve) => setTimeout(resolve, 300))
return this.isAuthenticated
}
async mockRenewToken(): Promise<{ accessToken: string; refreshToken: string }> {
console.log("🎭 Mock RENEW_TOKEN")
await new Promise((resolve) => setTimeout(resolve, 500))
if (!this.isAuthenticated) {
throw new Error("Not authenticated")
}
// Générer de nouveaux tokens mockés
this.mockTokens = {
accessToken: `mock_access_token_${Date.now()}`,
refreshToken: `mock_refresh_token_${Date.now()}`,
}
return this.mockTokens
}
// Utilitaires
isInMockMode(): boolean {
return this.isAuthenticated
}
disconnect(): void {
this.isAuthenticated = false
console.log("🎭 Mock disconnected")
}
// Données supplémentaires pour les écrans
getMockStats() {
return {
totalDocuments: this.mockProcesses.filter((p) => p.type === "document").length,
totalFolders: this.mockProcesses.filter((p) => p.type === "folder").length,
myProcesses: this.mockMyProcesses.length,
pendingValidations: this.mockProcesses.filter((p) => p.status === "pending" || p.status === "draft").length,
validatedProcesses: this.mockProcesses.filter((p) => p.status === "validated").length,
}
}
getMockRecentActivity() {
return this.mockProcesses
.sort((a, b) => new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime())
.slice(0, 5)
.map((process) => ({
id: process.id,
name: process.name,
type: process.type,
status: process.status,
updated_at: process.updated_at,
}))
}
}

View File

@ -1,43 +1,59 @@
export default class UserStore {
private static instance: UserStore;
/**
* UserStore - Singleton pour la gestion des tokens et identifiants utilisateur
* Stockage en sessionStorage selon les spécifications 4NK
*/
export class UserStore {
private static instance: UserStore | null = null
private constructor() { }
private constructor() {}
public static getInstance(): UserStore {
static getInstance(): UserStore {
if (!UserStore.instance) {
UserStore.instance = new UserStore();
UserStore.instance = new UserStore()
}
return UserStore.instance;
return UserStore.instance
}
public connect(accessToken: string, refreshToken: string): void {
sessionStorage.setItem('accessToken', accessToken);
sessionStorage.setItem('refreshToken', refreshToken);
connect(accessToken: string, refreshToken: string): void {
if (typeof window !== "undefined") {
sessionStorage.setItem("4nk_access_token", accessToken)
sessionStorage.setItem("4nk_refresh_token", refreshToken)
}
}
public isConnected(): boolean {
return sessionStorage.getItem('accessToken') !== null && sessionStorage.getItem('refreshToken') !== null;
isConnected(): boolean {
if (typeof window === "undefined") return false
const accessToken = sessionStorage.getItem("4nk_access_token")
const refreshToken = sessionStorage.getItem("4nk_refresh_token")
return !!(accessToken && refreshToken)
}
public disconnect(): void {
sessionStorage.removeItem('accessToken');
sessionStorage.removeItem('refreshToken');
sessionStorage.removeItem('userPairingId');
disconnect(): void {
if (typeof window !== "undefined") {
sessionStorage.removeItem("4nk_access_token")
sessionStorage.removeItem("4nk_refresh_token")
sessionStorage.removeItem("4nk_user_pairing_id")
}
}
public getAccessToken(): string | null {
return sessionStorage.getItem('accessToken');
getAccessToken(): string | null {
if (typeof window === "undefined") return null
return sessionStorage.getItem("4nk_access_token")
}
public getRefreshToken(): string | null {
return sessionStorage.getItem('refreshToken');
getRefreshToken(): string | null {
if (typeof window === "undefined") return null
return sessionStorage.getItem("4nk_refresh_token")
}
public pair(userPairingId: string): void {
sessionStorage.setItem('userPairingId', userPairingId);
pair(userPairingId: string): void {
if (typeof window !== "undefined") {
sessionStorage.setItem("4nk_user_pairing_id", userPairingId)
}
}
public getUserPairingId(): string | null {
return sessionStorage.getItem('userPairingId');
getUserPairingId(): string | null {
if (typeof window === "undefined") return null
return sessionStorage.getItem("4nk_user_pairing_id")
}
}

View File

@ -1,15 +0,0 @@
export interface FileBlob {
type: string,
data: Uint8Array
};
export function isFileBlob(data: any): data is FileBlob {
return (
typeof data === 'object' &&
data !== null &&
'type' in data &&
typeof data.type === 'string' &&
'data' in data &&
data.data instanceof Uint8Array
);
}

View File

@ -1,137 +0,0 @@
import { isFileBlob, type FileBlob } from "./Data";
import type { RoleDefinition } from "./Roles";
export interface FolderData {
folderNumber: string;
name: string;
deedType: string;
description: string;
archived_description: string;
status: string;
created_at: string;
updated_at: string;
customers: string[];
documents: FileBlob[];
motes: string[];
stakeholders: string[];
}
export function isFolderData(data: any): data is FolderData {
if (typeof data !== 'object' || data === null) return false;
const requiredStringFields = [
'folderNumber',
'name',
'deedType',
'description',
'archived_description',
'status',
'created_at',
'updated_at'
];
for (const field of requiredStringFields) {
if (typeof data[field] !== 'string') return false;
}
const requiredArrayFields = [
'customers',
'motes',
'stakeholders'
];
for (const field of requiredArrayFields) {
if (!Array.isArray(data[field]) || !data[field].every((item: any) => typeof item === 'string')) {
return false;
}
}
const requiredFileBlobArrayFields = [
'documents',
];
for (const field of requiredFileBlobArrayFields) {
if (!Array.isArray(data[field])) return false;
if (data[field].length > 0 && !data[field].every(isFileBlob)) return false;
}
return true;
}
const emptyFolderData: FolderData = {
folderNumber: '',
name: '',
deedType: '',
description: '',
archived_description: '',
status: '',
created_at: '',
updated_at: '',
customers: [],
documents: [],
motes: [],
stakeholders: []
};
const folderDataFields: string[] = Object.keys(emptyFolderData);
const FolderPublicFields: string[] = [];
// All the attributes are private in that case
export const FolderPrivateFields = [
...folderDataFields.filter(key => !FolderPublicFields.includes(key))
];
export interface FolderCreated {
processId: string,
process: any, // Process
folderData: FolderData,
}
export function setDefaultFolderRoles(ownerId: string, stakeholdersId: string[], customersId: string[]): Record<string, RoleDefinition> {
return {
demiurge: {
members: [ownerId],
validation_rules: [],
storages: []
},
owner: {
members: [ownerId],
validation_rules: [
{
quorum: 0.5,
fields: [...folderDataFields, 'roles'],
min_sig_member: 1,
},
],
storages: []
},
stakeholders: {
members: stakeholdersId,
validation_rules: [
{
quorum: 0.5,
fields: ['documents', 'motes'],
min_sig_member: 1,
},
],
storages: []
},
customers: {
members: customersId,
validation_rules: [
{
quorum: 0.0,
fields: folderDataFields,
min_sig_member: 0.0,
},
],
storages: []
},
apophis: {
members: [ownerId],
validation_rules: [],
storages: []
}
}
};

View File

@ -1,122 +0,0 @@
import { isFileBlob, type FileBlob } from "./Data";
import type { RoleDefinition } from "./Roles";
export interface ProfileData {
name: string;
surname: string;
email: string;
phone: string;
address: string;
postalCode: string;
city: string;
country: string;
idDocument: FileBlob | null;
idCertified: boolean;
}
export function isProfileData(data: any): data is ProfileData{
if (typeof data !== 'object' || data === null) return false;
const requiredStringFields = [
'name',
'surname',
'email',
'phone',
'address',
'postalCode',
'city',
'country',
];
for (const field of requiredStringFields) {
if (typeof data[field] !== 'string') return false;
}
const requiredBooleanFields = [
'idCertified',
];
for (const field of requiredBooleanFields) {
if (typeof data[field] !== 'boolean') return false;
}
const requiredFileFields = [
'idDocument',
];
for (const field of requiredFileFields) {
if (!isFileBlob(data[field]) && data[field] !== null) return false;
}
return true;
}
const emptyProfileData: ProfileData = {
name: '',
surname: '',
email: '',
phone: '',
address: '',
postalCode: '',
city: '',
country: '',
idDocument: null,
idCertified: false,
};
const profileDataFields: string[] = Object.keys(emptyProfileData);
const ProfilePublicFields: string[] = ['idCertified'];
export const ProfilePrivateFields = [
...profileDataFields.filter(key => !ProfilePublicFields.includes(key))
];
export interface ProfileCreated {
processId: string,
process: any, // Process
profileData: ProfileData,
}
export function setDefaultProfileRoles(ownerId: string[], validatorId: string): Record<string, RoleDefinition> {
return {
demiurge: {
members: [...ownerId, validatorId],
validation_rules: [],
storages: []
},
owner: {
members: ownerId,
validation_rules: [
{
quorum: 0.5,
fields: [...ProfilePrivateFields, 'roles'],
min_sig_member: 1,
},
],
storages: []
},
validator: {
members: [validatorId],
validation_rules: [
{
quorum: 0.5,
fields: ['idCertified', 'roles'],
min_sig_member: 1,
},
{
quorum: 0.0,
fields: [...profileDataFields],
min_sig_member: 0,
},
],
storages: []
},
apophis: {
members: ownerId,
validation_rules: [],
storages: []
}
}
};

View File

@ -1,11 +0,0 @@
export interface ValidationRule {
quorum: number,
fields: string[],
min_sig_member: number,
}
export interface RoleDefinition {
members: string[],
validation_rules: ValidationRule[],
storages: string[]
}

36
package-lock.json generated
View File

@ -48,9 +48,9 @@
"next": "15.2.4",
"next-themes": "latest",
"nodemailer": "latest",
"react": "^19.1.1",
"react": "^19",
"react-day-picker": "9.8.0",
"react-dom": "^19.1.1",
"react-dom": "^19",
"react-hook-form": "^7.60.0",
"react-resizable-panels": "^2.1.7",
"recharts": "2.15.4",
@ -58,7 +58,7 @@
"tailwind-merge": "^2.5.5",
"tailwindcss-animate": "^1.0.7",
"uuid": "latest",
"vaul": "^1.1.2",
"vaul": "^0.9.9",
"zod": "3.25.67"
},
"devDependencies": {
@ -3730,9 +3730,8 @@
"version": "19.1.10",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.10.tgz",
"integrity": "sha512-EhBeSYX0Y6ye8pNebpKrwFJq7BoQ8J5SO6NlvNwwHjSj6adXJViPQrKlsyPw7hLBLvckEMO1yxeGdR82YBBlDg==",
"devOptional": true,
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"csstype": "^3.0.2"
}
@ -3741,9 +3740,8 @@
"version": "19.1.7",
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.1.7.tgz",
"integrity": "sha512-i5ZzwYpqjmrKenzkoLM2Ibzt6mAsM7pxB6BCIouEVVmgiqaMj1TjaK7hnA36hbW5aZv20kx7Lw6hWzPWg0Rurw==",
"devOptional": true,
"dev": true,
"license": "MIT",
"peer": true,
"peerDependencies": {
"@types/react": "^19.0.0"
}
@ -3823,7 +3821,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"caniuse-lite": "^1.0.30001733",
"electron-to-chromium": "^1.5.199",
@ -4151,8 +4148,7 @@
"version": "8.5.1",
"resolved": "https://registry.npmjs.org/embla-carousel/-/embla-carousel-8.5.1.tgz",
"integrity": "sha512-JUb5+FOHobSiWQ2EJNaueCNT/cQU9L6XWBbWmorWPQT9bkbk+fhsuLr8wWrzXKagO3oWszBO7MSx+GfaRk4E6A==",
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/embla-carousel-react": {
"version": "8.5.1",
@ -4632,7 +4628,6 @@
"resolved": "https://registry.npmjs.org/next/-/next-15.2.4.tgz",
"integrity": "sha512-VwL+LAaPSxEkd3lU2xWbgEOtrM8oedmyhBqaVNmgKB+GvZlCy9rgaEc+y2on0wv+l0oSFqLtYD6dcC1eAedUaQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@next/env": "15.2.4",
"@swc/counter": "0.1.3",
@ -4763,6 +4758,7 @@
"version": "8.5.6",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
"integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==",
"dev": true,
"funding": [
{
"type": "opencollective",
@ -4778,7 +4774,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"nanoid": "^3.3.11",
"picocolors": "^1.1.1",
@ -4816,7 +4811,6 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.1.1.tgz",
"integrity": "sha512-w8nqGImo45dmMIfljjMwOGtbmC/mk4CMYhWIicdSflH91J9TyCyczcPFXJzrZ/ZXcgGRFeP6BU0BEJTw6tZdfQ==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@ -4847,7 +4841,6 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.1.tgz",
"integrity": "sha512-Dlq/5LAZgF0Gaz6yiqZCf6VCcZs1ghAJyrsu84Q/GT0gV+mCxbfmKNoGRKBYMJ8IEdGPqu49YWXD02GCknEDkw==",
"license": "MIT",
"peer": true,
"dependencies": {
"scheduler": "^0.26.0"
},
@ -4860,7 +4853,6 @@
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.62.0.tgz",
"integrity": "sha512-7KWFejc98xqG/F4bAxpL41NB3o1nnvQO1RWZT3TqRZYL8RryQETGfEdVnJN2fy1crCiBLLjkRBVK05j24FxJGA==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18.0.0"
},
@ -5153,8 +5145,8 @@
"version": "4.1.11",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.11.tgz",
"integrity": "sha512-2E9TBm6MDD/xKYe+dvJZAmg3yxIEDNRc0jwlNyDg/4Fil2QcSLjFKGVff0lAf1jjeaArlG/M75Ey/EYr/OJtBA==",
"license": "MIT",
"peer": true
"dev": true,
"license": "MIT"
},
"node_modules/tailwindcss-animate": {
"version": "1.0.7",
@ -5332,16 +5324,16 @@
}
},
"node_modules/vaul": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/vaul/-/vaul-1.1.2.tgz",
"integrity": "sha512-ZFkClGpWyI2WUQjdLJ/BaGuV6AVQiJ3uELGk3OYtP+B6yCO7Cmn9vPFXVJkRaGkOJu3m8bQMgtyzNHixULceQA==",
"version": "0.9.9",
"resolved": "https://registry.npmjs.org/vaul/-/vaul-0.9.9.tgz",
"integrity": "sha512-7afKg48srluhZwIkaU+lgGtFCUsYBSGOl8vcc8N/M3YQlZFlynHD15AE+pwrYdc826o7nrIND4lL9Y6b9WWZZQ==",
"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"
"react": "^16.8 || ^17.0 || ^18.0",
"react-dom": "^16.8 || ^17.0 || ^18.0"
}
},
"node_modules/victory-vendor": {

View File

@ -49,9 +49,9 @@
"next": "15.2.4",
"next-themes": "latest",
"nodemailer": "latest",
"react": "^19.1.1",
"react": "^19",
"react-day-picker": "9.8.0",
"react-dom": "^19.1.1",
"react-dom": "^19",
"react-hook-form": "^7.60.0",
"react-resizable-panels": "^2.1.7",
"recharts": "2.15.4",
@ -59,7 +59,7 @@
"tailwind-merge": "^2.5.5",
"tailwindcss-animate": "^1.0.7",
"uuid": "latest",
"vaul": "^1.1.2",
"vaul": "^0.9.9",
"zod": "3.25.67"
},
"devDependencies": {