mirror of
https://github.com/tips-of-mine/gestion-certificats2.git
synced 2025-06-28 01:18:42 +02:00
Modernisation du projet Gestion Certificat
This commit is contained in:
24
.env.example
Normal file
24
.env.example
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
# Base de données
|
||||||
|
DB_HOST=mysql
|
||||||
|
DB_NAME=cert_gestion
|
||||||
|
DB_USER=user
|
||||||
|
DB_PASSWORD=password_secret
|
||||||
|
|
||||||
|
# Application
|
||||||
|
APP_NAME="Certificate Management"
|
||||||
|
APP_ENV=development
|
||||||
|
APP_LOG_PATH=/var/log/app/app.log
|
||||||
|
|
||||||
|
# Sécurité
|
||||||
|
SESSION_SECRET=your-super-secret-session-key-change-in-production
|
||||||
|
|
||||||
|
# PKI Configuration
|
||||||
|
ROOT_CA_PATH=/opt/tls/root
|
||||||
|
INTERMEDIATE_CA_PATH_BASE=/opt/tls/intermediate
|
||||||
|
SCRIPTS_PATH=/opt/scripts
|
||||||
|
|
||||||
|
# OCSP
|
||||||
|
OCSP_URL=http://ocsp.cert-gestion.local/
|
||||||
|
|
||||||
|
# Frontend
|
||||||
|
VITE_API_BASE_URL=http://localhost:980/api/v1
|
18
Dockerfile.frontend.dev
Normal file
18
Dockerfile.frontend.dev
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
FROM node:18-alpine
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Copier les fichiers de dépendances
|
||||||
|
COPY package*.json ./
|
||||||
|
|
||||||
|
# Installer les dépendances
|
||||||
|
RUN npm ci
|
||||||
|
|
||||||
|
# Copier le code source
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Exposer le port de développement
|
||||||
|
EXPOSE 3000
|
||||||
|
|
||||||
|
# Commande de développement avec hot reload
|
||||||
|
CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0"]
|
197
README.modern.md
Normal file
197
README.modern.md
Normal file
@ -0,0 +1,197 @@
|
|||||||
|
# Certificate Management System - Version Modernisée
|
||||||
|
|
||||||
|
## 🚀 Nouvelle Architecture
|
||||||
|
|
||||||
|
Cette version modernisée utilise les technologies suivantes :
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
- **React 18** avec TypeScript
|
||||||
|
- **Vite** pour le build et le développement
|
||||||
|
- **Tailwind CSS** pour le styling
|
||||||
|
- **React Query** pour la gestion des données
|
||||||
|
- **Zustand** pour le state management
|
||||||
|
- **React Hook Form** pour les formulaires
|
||||||
|
- **i18next** pour l'internationalisation
|
||||||
|
|
||||||
|
### Backend
|
||||||
|
- **PHP 8.3** avec architecture API REST
|
||||||
|
- **API versionnée** (v1) avec responses standardisées
|
||||||
|
- **Middleware d'authentification** moderne
|
||||||
|
- **OpenAPI/Swagger** documentation
|
||||||
|
- **Validation robuste** des données
|
||||||
|
|
||||||
|
### DevOps
|
||||||
|
- **Docker multi-stage** builds
|
||||||
|
- **Hot reload** en développement
|
||||||
|
- **Environnements séparés** (dev/prod)
|
||||||
|
- **Healthchecks** et monitoring
|
||||||
|
- **CI/CD** amélioré avec tests automatisés
|
||||||
|
|
||||||
|
## 🛠 Installation et Développement
|
||||||
|
|
||||||
|
### Prérequis
|
||||||
|
- Docker et Docker Compose
|
||||||
|
- Node.js 18+ (pour le développement local)
|
||||||
|
|
||||||
|
### Démarrage rapide
|
||||||
|
|
||||||
|
1. **Clone et configuration :**
|
||||||
|
```bash
|
||||||
|
git clone <repository>
|
||||||
|
cd certificate-management
|
||||||
|
cp .env.example .env
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Développement avec hot reload :**
|
||||||
|
```bash
|
||||||
|
# Démarrer tous les services en mode développement
|
||||||
|
docker-compose -f docker-compose.dev.yml up -d
|
||||||
|
|
||||||
|
# Ou démarrer individuellement
|
||||||
|
npm install # Installer les dépendances frontend
|
||||||
|
npm run dev # Frontend avec hot reload sur :3000
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Production :**
|
||||||
|
```bash
|
||||||
|
docker-compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
### URLs de développement
|
||||||
|
- **Frontend**: http://localhost:3000
|
||||||
|
- **Backend API**: http://localhost:980/api/v1
|
||||||
|
- **Application Legacy**: http://localhost:980
|
||||||
|
|
||||||
|
## 📱 Nouvelles Fonctionnalités
|
||||||
|
|
||||||
|
### Interface Utilisateur
|
||||||
|
- **Design System** moderne avec Tailwind CSS
|
||||||
|
- **Mode sombre/clair** avec persistence
|
||||||
|
- **Interface responsive** optimisée mobile
|
||||||
|
- **Composants réutilisables** avec TypeScript
|
||||||
|
- **Loading states** et error handling améliorés
|
||||||
|
- **Toast notifications** pour les feedbacks
|
||||||
|
|
||||||
|
### API REST
|
||||||
|
- **Endpoints RESTful** standardisés
|
||||||
|
- **Responses JSON** unifiées
|
||||||
|
- **Authentification par token** (Bearer)
|
||||||
|
- **Validation des données** robuste
|
||||||
|
- **Codes d'erreur HTTP** appropriés
|
||||||
|
- **Documentation API** automatique
|
||||||
|
|
||||||
|
### Gestion des États
|
||||||
|
- **State management** avec Zustand
|
||||||
|
- **Cache intelligent** avec React Query
|
||||||
|
- **Optimistic updates** pour une UX fluide
|
||||||
|
- **Persistance** des préférences utilisateur
|
||||||
|
|
||||||
|
### Sécurité Renforcée
|
||||||
|
- **Headers de sécurité** (CORS, CSP)
|
||||||
|
- **Validation stricte** côté client et serveur
|
||||||
|
- **Sanitization** des inputs
|
||||||
|
- **Rate limiting** sur les APIs sensibles
|
||||||
|
|
||||||
|
## 🧪 Tests et Qualité
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
```bash
|
||||||
|
npm run test # Tests unitaires avec Vitest
|
||||||
|
npm run test:ui # Interface de test
|
||||||
|
npm run test:coverage # Couverture de code
|
||||||
|
npm run lint # ESLint
|
||||||
|
npm run type-check # Vérification TypeScript
|
||||||
|
```
|
||||||
|
|
||||||
|
### Backend
|
||||||
|
```bash
|
||||||
|
# Dans le conteneur PHP
|
||||||
|
composer test # PHPUnit
|
||||||
|
composer analyse # Analyse statique
|
||||||
|
```
|
||||||
|
|
||||||
|
### CI/CD
|
||||||
|
- **Tests automatisés** sur chaque PR
|
||||||
|
- **Build multi-environnements**
|
||||||
|
- **Déploiement automatique** avec validation
|
||||||
|
- **Monitoring** de la santé des services
|
||||||
|
|
||||||
|
## 📊 Monitoring et Observabilité
|
||||||
|
|
||||||
|
### Métriques
|
||||||
|
- **Performance monitoring** des APIs
|
||||||
|
- **Error tracking** centralisé
|
||||||
|
- **Usage analytics** des fonctionnalités
|
||||||
|
- **Alertes** en cas de problème
|
||||||
|
|
||||||
|
### Logs Structurés
|
||||||
|
- **Logs JSON** pour faciliter l'analyse
|
||||||
|
- **Corrélation** des requêtes
|
||||||
|
- **Niveaux de logs** appropriés
|
||||||
|
- **Rotation automatique** des logs
|
||||||
|
|
||||||
|
## 🔄 Migration depuis l'Ancienne Version
|
||||||
|
|
||||||
|
L'ancienne interface PHP reste accessible et fonctionne en parallèle. La migration se fait progressivement :
|
||||||
|
|
||||||
|
1. **Phase 1** : API REST en parallèle ✅
|
||||||
|
2. **Phase 2** : Nouvelle interface React ✅
|
||||||
|
3. **Phase 3** : Migration des utilisateurs
|
||||||
|
4. **Phase 4** : Dépréciation de l'ancienne interface
|
||||||
|
|
||||||
|
## 🚧 Roadmap
|
||||||
|
|
||||||
|
### Version 2.1 (Q1 2024)
|
||||||
|
- [ ] **WebSocket** pour les notifications temps réel
|
||||||
|
- [ ] **Dashboard avancé** avec graphiques
|
||||||
|
- [ ] **Audit trail** complet
|
||||||
|
- [ ] **API Rate limiting** avancé
|
||||||
|
|
||||||
|
### Version 2.2 (Q2 2024)
|
||||||
|
- [ ] **Multi-tenancy** pour les organisations
|
||||||
|
- [ ] **SSO/SAML** integration
|
||||||
|
- [ ] **Backup automatique** des certificats
|
||||||
|
- [ ] **Mobile app** React Native
|
||||||
|
|
||||||
|
### Version 3.0 (Q3 2024)
|
||||||
|
- [ ] **Microservices** architecture
|
||||||
|
- [ ] **Kubernetes** deployment
|
||||||
|
- [ ] **GraphQL** API
|
||||||
|
- [ ] **Machine Learning** pour la détection d'anomalies
|
||||||
|
|
||||||
|
## 📖 Documentation
|
||||||
|
|
||||||
|
- [Guide d'installation détaillé](./docs/installation.md)
|
||||||
|
- [Documentation API](./docs/API.md)
|
||||||
|
- [Guide de développement](./docs/development.md)
|
||||||
|
- [Architecture](./docs/architecture.md)
|
||||||
|
- [Sécurité](./docs/security.md)
|
||||||
|
|
||||||
|
## 🤝 Contribution
|
||||||
|
|
||||||
|
1. Fork le projet
|
||||||
|
2. Créer une branche feature (`git checkout -b feature/amazing-feature`)
|
||||||
|
3. Commit les 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
|
||||||
|
- **ESLint + Prettier** pour JavaScript/TypeScript
|
||||||
|
- **PSR-12** pour PHP
|
||||||
|
- **Conventional Commits** pour les messages
|
||||||
|
- **Tests obligatoires** pour les nouvelles fonctionnalités
|
||||||
|
|
||||||
|
## 📄 Licence
|
||||||
|
|
||||||
|
Ce projet est sous licence AGPL-3.0. Voir le fichier [LICENSE](LICENSE) pour plus de détails.
|
||||||
|
|
||||||
|
## 🆘 Support
|
||||||
|
|
||||||
|
- **Issues GitHub** pour les bugs
|
||||||
|
- **Discussions** pour les questions
|
||||||
|
- **Wiki** pour la documentation communautaire
|
||||||
|
- **Discord** pour le chat en temps réel
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Made with ❤️ by the Certificate Management Team**
|
74
app/public/api.php
Normal file
74
app/public/api.php
Normal file
@ -0,0 +1,74 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
// Point d'entrée pour les API V1
|
||||||
|
session_start();
|
||||||
|
|
||||||
|
// Inclusion des fichiers fondamentaux
|
||||||
|
require_once __DIR__ . '/../src/Core/Autoloader.php';
|
||||||
|
require_once __DIR__ . '/../src/Core/Database.php';
|
||||||
|
require_once __DIR__ . '/../src/config/app.php';
|
||||||
|
|
||||||
|
// Enregistrement de l'autoloader
|
||||||
|
\App\Core\Autoloader::register();
|
||||||
|
|
||||||
|
use App\Core\Database;
|
||||||
|
use App\Api\V1\Router;
|
||||||
|
use App\Services\LogService;
|
||||||
|
|
||||||
|
// Initialisation de la connexion à la base de données
|
||||||
|
try {
|
||||||
|
Database::connect(DB_HOST, DB_NAME, DB_USER, DB_PASSWORD);
|
||||||
|
} catch (PDOException $e) {
|
||||||
|
error_log("API: Database connection error: " . $e->getMessage());
|
||||||
|
http_response_code(500);
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
echo json_encode(['success' => false, 'message' => 'Database connection failed']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Headers CORS pour les requêtes cross-origin
|
||||||
|
header('Access-Control-Allow-Origin: *');
|
||||||
|
header('Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS');
|
||||||
|
header('Access-Control-Allow-Headers: Content-Type, Authorization, X-Requested-With');
|
||||||
|
|
||||||
|
// Gérer les requêtes OPTIONS (preflight)
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
|
||||||
|
http_response_code(200);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log des requêtes API
|
||||||
|
$logService = new LogService(APP_LOG_PATH);
|
||||||
|
$logService->log('info', 'API Request: ' . $_SERVER['REQUEST_METHOD'] . ' ' . $_SERVER['REQUEST_URI'], null, $_SERVER['REMOTE_ADDR']);
|
||||||
|
|
||||||
|
// Configuration du routeur API
|
||||||
|
$router = new Router();
|
||||||
|
|
||||||
|
// Routes d'authentification
|
||||||
|
$router->addRoute('POST', '/auth/login', 'AuthController', 'login');
|
||||||
|
$router->addRoute('POST', '/auth/logout', 'AuthController', 'logout', true);
|
||||||
|
$router->addRoute('GET', '/auth/me', 'AuthController', 'me', true);
|
||||||
|
|
||||||
|
// Routes des certificats
|
||||||
|
$router->addRoute('GET', '/certificates', 'CertificatesController', 'index', true);
|
||||||
|
$router->addRoute('POST', '/certificates', 'CertificatesController', 'create', true);
|
||||||
|
$router->addRoute('POST', '/certificates/{id}/revoke', 'CertificatesController', 'revoke', true);
|
||||||
|
$router->addRoute('GET', '/certificates/download', 'CertificatesController', 'download', true);
|
||||||
|
$router->addRoute('GET', '/certificates/stats', 'CertificatesController', 'stats', true);
|
||||||
|
|
||||||
|
// Routes des périmètres
|
||||||
|
$router->addRoute('GET', '/perimeters', 'PerimetersController', 'index', true);
|
||||||
|
$router->addRoute('POST', '/perimeters', 'PerimetersController', 'create', true);
|
||||||
|
|
||||||
|
// Routes des utilisateurs
|
||||||
|
$router->addRoute('GET', '/users', 'UsersController', 'index', true);
|
||||||
|
$router->addRoute('POST', '/users', 'UsersController', 'create', true);
|
||||||
|
$router->addRoute('DELETE', '/users/{id}', 'UsersController', 'delete', true);
|
||||||
|
$router->addRoute('PUT', '/users/{id}/role', 'UsersController', 'updateRole', true);
|
||||||
|
$router->addRoute('PUT', '/users/{id}/password', 'UsersController', 'updatePassword', true);
|
||||||
|
|
||||||
|
// Route du dashboard
|
||||||
|
$router->addRoute('GET', '/dashboard/stats', 'DashboardController', 'stats', true);
|
||||||
|
|
||||||
|
// Dispatche la requête
|
||||||
|
$router->dispatch();
|
113
app/src/Api/V1/Controllers/AuthController.php
Normal file
113
app/src/Api/V1/Controllers/AuthController.php
Normal file
@ -0,0 +1,113 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Api\V1\Controllers;
|
||||||
|
|
||||||
|
use App\Core\Database;
|
||||||
|
use App\Services\AuthService;
|
||||||
|
use App\Services\LogService;
|
||||||
|
use App\Services\LanguageService;
|
||||||
|
use App\Api\V1\Responses\ApiResponse;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API Controller pour l'authentification.
|
||||||
|
*/
|
||||||
|
class AuthController
|
||||||
|
{
|
||||||
|
private $authService;
|
||||||
|
private $logService;
|
||||||
|
private $langService;
|
||||||
|
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
$db = Database::getInstance();
|
||||||
|
$this->authService = new AuthService($db);
|
||||||
|
$this->logService = new LogService(APP_LOG_PATH);
|
||||||
|
$this->langService = new LanguageService(APP_ROOT_DIR . '/src/Lang/');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Connexion utilisateur
|
||||||
|
* POST /api/v1/auth/login
|
||||||
|
*/
|
||||||
|
public function login()
|
||||||
|
{
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||||
|
return ApiResponse::methodNotAllowed();
|
||||||
|
}
|
||||||
|
|
||||||
|
$input = json_decode(file_get_contents('php://input'), true);
|
||||||
|
|
||||||
|
if (!$input || !isset($input['username']) || !isset($input['password'])) {
|
||||||
|
return ApiResponse::badRequest('Username and password are required');
|
||||||
|
}
|
||||||
|
|
||||||
|
$username = trim($input['username']);
|
||||||
|
$password = $input['password'];
|
||||||
|
$ipAddress = $_SERVER['REMOTE_ADDR'];
|
||||||
|
|
||||||
|
if (empty($username) || empty($password)) {
|
||||||
|
return ApiResponse::badRequest('Username and password cannot be empty');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->authService->login($username, $password, $ipAddress)) {
|
||||||
|
// Génération d'un token JWT simple ou utilisation de la session
|
||||||
|
$user = [
|
||||||
|
'id' => $this->authService->getUserId(),
|
||||||
|
'username' => $this->authService->getUsername(),
|
||||||
|
'role' => $this->authService->getUserRole(),
|
||||||
|
'token' => $this->generateSimpleToken($this->authService->getUserId())
|
||||||
|
];
|
||||||
|
|
||||||
|
return ApiResponse::success($user, 'Login successful');
|
||||||
|
} else {
|
||||||
|
return ApiResponse::unauthorized('Invalid credentials');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Déconnexion utilisateur
|
||||||
|
* POST /api/v1/auth/logout
|
||||||
|
*/
|
||||||
|
public function logout()
|
||||||
|
{
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||||
|
return ApiResponse::methodNotAllowed();
|
||||||
|
}
|
||||||
|
|
||||||
|
$ipAddress = $_SERVER['REMOTE_ADDR'];
|
||||||
|
$this->authService->logout($ipAddress);
|
||||||
|
|
||||||
|
return ApiResponse::success(null, 'Logout successful');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Récupérer les informations de l'utilisateur connecté
|
||||||
|
* GET /api/v1/auth/me
|
||||||
|
*/
|
||||||
|
public function me()
|
||||||
|
{
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
|
||||||
|
return ApiResponse::methodNotAllowed();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$this->authService->isLoggedIn()) {
|
||||||
|
return ApiResponse::unauthorized('Not authenticated');
|
||||||
|
}
|
||||||
|
|
||||||
|
$user = [
|
||||||
|
'id' => $this->authService->getUserId(),
|
||||||
|
'username' => $this->authService->getUsername(),
|
||||||
|
'role' => $this->authService->getUserRole(),
|
||||||
|
];
|
||||||
|
|
||||||
|
return ApiResponse::success($user);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Génère un token simple (à remplacer par JWT en production)
|
||||||
|
*/
|
||||||
|
private function generateSimpleToken($userId)
|
||||||
|
{
|
||||||
|
return base64_encode($userId . ':' . time() . ':' . SESSION_SECRET);
|
||||||
|
}
|
||||||
|
}
|
438
app/src/Api/V1/Controllers/CertificatesController.php
Normal file
438
app/src/Api/V1/Controllers/CertificatesController.php
Normal file
@ -0,0 +1,438 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Api\V1\Controllers;
|
||||||
|
|
||||||
|
use App\Core\Database;
|
||||||
|
use App\Services\AuthService;
|
||||||
|
use App\Services\LogService;
|
||||||
|
use App\Services\LanguageService;
|
||||||
|
use App\Api\V1\Responses\ApiResponse;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API Controller pour la gestion des certificats.
|
||||||
|
*/
|
||||||
|
class CertificatesController
|
||||||
|
{
|
||||||
|
private $db;
|
||||||
|
private $authService;
|
||||||
|
private $logService;
|
||||||
|
private $langService;
|
||||||
|
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
$this->db = Database::getInstance();
|
||||||
|
$this->authService = new AuthService($this->db);
|
||||||
|
$this->logService = new LogService(APP_LOG_PATH);
|
||||||
|
$this->langService = new LanguageService(APP_ROOT_DIR . '/src/Lang/');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Récupérer tous les certificats avec pagination
|
||||||
|
* GET /api/v1/certificates
|
||||||
|
*/
|
||||||
|
public function index()
|
||||||
|
{
|
||||||
|
if (!$this->authService->isLoggedIn()) {
|
||||||
|
return ApiResponse::unauthorized();
|
||||||
|
}
|
||||||
|
|
||||||
|
$page = isset($_GET['page']) ? max(1, intval($_GET['page'])) : 1;
|
||||||
|
$perPage = isset($_GET['per_page']) ? min(100, max(1, intval($_GET['per_page']))) : 50;
|
||||||
|
$offset = ($page - 1) * $perPage;
|
||||||
|
|
||||||
|
// Compter le total
|
||||||
|
$totalStmt = $this->db->query("
|
||||||
|
SELECT COUNT(*) as total
|
||||||
|
FROM certificates c
|
||||||
|
LEFT JOIN functional_perimeters fp ON c.functional_perimeter_id = fp.id
|
||||||
|
");
|
||||||
|
$total = $totalStmt->fetch()['total'];
|
||||||
|
|
||||||
|
// Récupérer les certificats avec pagination
|
||||||
|
$stmt = $this->db->prepare("
|
||||||
|
SELECT
|
||||||
|
c.id, c.name, c.type, c.expiration_date, c.is_revoked, c.revoked_at, c.created_at,
|
||||||
|
fp.name as perimeter_name
|
||||||
|
FROM certificates c
|
||||||
|
LEFT JOIN functional_perimeters fp ON c.functional_perimeter_id = fp.id
|
||||||
|
ORDER BY fp.name IS NULL DESC, fp.name ASC, c.type DESC, c.expiration_date DESC
|
||||||
|
LIMIT ? OFFSET ?
|
||||||
|
");
|
||||||
|
$stmt->execute([$perPage, $offset]);
|
||||||
|
$certificates = $stmt->fetchAll();
|
||||||
|
|
||||||
|
$response = [
|
||||||
|
'data' => $certificates,
|
||||||
|
'current_page' => $page,
|
||||||
|
'per_page' => $perPage,
|
||||||
|
'total' => $total,
|
||||||
|
'last_page' => ceil($total / $perPage)
|
||||||
|
];
|
||||||
|
|
||||||
|
return ApiResponse::success($response);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Créer un nouveau certificat
|
||||||
|
* POST /api/v1/certificates
|
||||||
|
*/
|
||||||
|
public function create()
|
||||||
|
{
|
||||||
|
if (!$this->authService->isLoggedIn()) {
|
||||||
|
return ApiResponse::unauthorized();
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||||
|
return ApiResponse::methodNotAllowed();
|
||||||
|
}
|
||||||
|
|
||||||
|
$input = json_decode(file_get_contents('php://input'), true);
|
||||||
|
|
||||||
|
if (!$input || !isset($input['subdomain_name']) || !isset($input['functional_perimeter_id'])) {
|
||||||
|
return ApiResponse::badRequest('Subdomain name and functional perimeter ID are required');
|
||||||
|
}
|
||||||
|
|
||||||
|
$subdomainName = trim($input['subdomain_name']);
|
||||||
|
$functionalPerimeterId = $input['functional_perimeter_id'];
|
||||||
|
$ipAddress = $_SERVER['REMOTE_ADDR'];
|
||||||
|
$userId = $this->authService->getUserId();
|
||||||
|
|
||||||
|
if (empty($subdomainName) || empty($functionalPerimeterId)) {
|
||||||
|
return ApiResponse::badRequest('Subdomain name and functional perimeter are required');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vérifier que le périmètre existe
|
||||||
|
$stmt = $this->db->prepare("SELECT name FROM functional_perimeters WHERE id = ?");
|
||||||
|
$stmt->execute([$functionalPerimeterId]);
|
||||||
|
$perimeter = $stmt->fetch();
|
||||||
|
|
||||||
|
if (!$perimeter) {
|
||||||
|
return ApiResponse::notFound('Functional perimeter not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
$functionalPerimeterName = $perimeter['name'];
|
||||||
|
|
||||||
|
// Extraire ROOT_DOMAIN du certificat CA racine pour SAN et OCSP
|
||||||
|
$rootCaCertPath = ROOT_CA_PATH . '/certs/ca.cert.pem';
|
||||||
|
if (!file_exists($rootCaCertPath)) {
|
||||||
|
$this->logService->log('error', "Certificat CA racine non trouvé pour extraction ROOT_DOMAIN lors de la création de cert feuille.", $userId, $ipAddress);
|
||||||
|
return ApiResponse::serverError('Root certificate not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
$subjectCommand = "openssl x509 -noout -subject -in " . escapeshellarg($rootCaCertPath);
|
||||||
|
$subjectLine = shell_exec($subjectCommand);
|
||||||
|
$rootDomain = null;
|
||||||
|
if ($subjectLine && preg_match('/CN\s*=\s*ca\.([^\/,\s]+)/', $subjectLine, $matches)) {
|
||||||
|
$rootDomain = $matches[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (empty($rootDomain)) {
|
||||||
|
$this->logService->log('error', "Impossible d'extraire ROOT_DOMAIN du cert CA racine (pour SAN).", $userId, $ipAddress);
|
||||||
|
return ApiResponse::serverError('Cannot extract root domain');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Construire la valeur SAN
|
||||||
|
$sanValue = "DNS:" . $subdomainName . "." . $functionalPerimeterName . "." . $rootDomain;
|
||||||
|
$ocspUrl = OCSP_URL;
|
||||||
|
|
||||||
|
// Préparer et exécuter la commande
|
||||||
|
$scriptPath = SCRIPTS_PATH . '/create_cert.sh';
|
||||||
|
$command = "OCSP_URL=" . escapeshellarg($ocspUrl) . " SAN=" . escapeshellarg($sanValue) . " " .
|
||||||
|
escapeshellcmd($scriptPath) . ' ' .
|
||||||
|
escapeshellarg($subdomainName) . ' ' .
|
||||||
|
escapeshellarg($functionalPerimeterName);
|
||||||
|
|
||||||
|
$this->logService->log('info', "Tentative de création du certificat '$subdomainName' pour le périmètre '$functionalPerimeterName'.", $userId, $ipAddress);
|
||||||
|
|
||||||
|
$output = shell_exec($command . ' 2>&1');
|
||||||
|
$certBaseNameForCheck = $subdomainName . '.' . $functionalPerimeterName;
|
||||||
|
|
||||||
|
if (strpos($output, "Certificat '" . $certBaseNameForCheck . "' créé avec succès :") !== false) {
|
||||||
|
// Calculer la date d'expiration
|
||||||
|
$certFileName = "{$subdomainName}.{$functionalPerimeterName}.cert.pem";
|
||||||
|
$fullCertPath = INTERMEDIATE_CA_PATH_BASE . "/{$functionalPerimeterName}/certs/{$certFileName}";
|
||||||
|
|
||||||
|
$expirationDate = (new \DateTime('+1 year'))->format('Y-m-d H:i:s');
|
||||||
|
if (file_exists($fullCertPath)) {
|
||||||
|
$certInfo = shell_exec("openssl x509 -in " . escapeshellarg($fullCertPath) . " -noout -enddate 2>/dev/null | cut -d= -f2");
|
||||||
|
$expirationTimestamp = strtotime($certInfo);
|
||||||
|
if ($expirationTimestamp) {
|
||||||
|
$expirationDate = date('Y-m-d H:i:s', $expirationTimestamp);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enregistrer en base
|
||||||
|
$stmt = $this->db->prepare("INSERT INTO certificates (name, type, functional_perimeter_id, expiration_date) VALUES (?, ?, ?, ?)");
|
||||||
|
$stmt->execute([$certFileName, 'simple', $functionalPerimeterId, $expirationDate]);
|
||||||
|
|
||||||
|
$certificateId = $this->db->lastInsertId();
|
||||||
|
|
||||||
|
// Récupérer le certificat créé
|
||||||
|
$stmt = $this->db->prepare("
|
||||||
|
SELECT c.*, fp.name as perimeter_name
|
||||||
|
FROM certificates c
|
||||||
|
LEFT JOIN functional_perimeters fp ON c.functional_perimeter_id = fp.id
|
||||||
|
WHERE c.id = ?
|
||||||
|
");
|
||||||
|
$stmt->execute([$certificateId]);
|
||||||
|
$certificate = $stmt->fetch();
|
||||||
|
|
||||||
|
$this->logService->log('info', "Certificat '{$certFileName}' créé et enregistré.", $userId, $ipAddress);
|
||||||
|
|
||||||
|
return ApiResponse::created($certificate, 'Certificate created successfully');
|
||||||
|
} else {
|
||||||
|
$this->logService->log('error', "Échec création certificat '$subdomainName': $output", $userId, $ipAddress);
|
||||||
|
return ApiResponse::serverError('Failed to create certificate', ['output' => $output]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Révoquer un certificat
|
||||||
|
* POST /api/v1/certificates/{id}/revoke
|
||||||
|
*/
|
||||||
|
public function revoke($certificateId)
|
||||||
|
{
|
||||||
|
if (!$this->authService->isLoggedIn()) {
|
||||||
|
return ApiResponse::unauthorized();
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||||
|
return ApiResponse::methodNotAllowed();
|
||||||
|
}
|
||||||
|
|
||||||
|
$ipAddress = $_SERVER['REMOTE_ADDR'];
|
||||||
|
$userId = $this->authService->getUserId();
|
||||||
|
|
||||||
|
if (empty($certificateId)) {
|
||||||
|
return ApiResponse::badRequest('Certificate ID is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Récupérer les informations du certificat
|
||||||
|
$stmt = $this->db->prepare("
|
||||||
|
SELECT c.name, c.type, c.is_revoked, fp.name as perimeter_name
|
||||||
|
FROM certificates c
|
||||||
|
LEFT JOIN functional_perimeters fp ON c.functional_perimeter_id = fp.id
|
||||||
|
WHERE c.id = ?
|
||||||
|
");
|
||||||
|
$stmt->execute([$certificateId]);
|
||||||
|
$cert = $stmt->fetch();
|
||||||
|
|
||||||
|
if (!$cert) {
|
||||||
|
return ApiResponse::notFound('Certificate not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($cert['type'] === 'root') {
|
||||||
|
return ApiResponse::forbidden('ROOT certificates cannot be revoked through the interface');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($cert['is_revoked']) {
|
||||||
|
return ApiResponse::badRequest('Certificate is already revoked');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Logique de révocation selon le type
|
||||||
|
if ($cert['type'] === 'intermediate') {
|
||||||
|
return $this->revokeIntermediateCertificate($cert, $certificateId, $userId, $ipAddress);
|
||||||
|
} else {
|
||||||
|
return $this->revokeSimpleCertificate($cert, $certificateId, $userId, $ipAddress);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Statistiques des certificats
|
||||||
|
* GET /api/v1/certificates/stats
|
||||||
|
*/
|
||||||
|
public function stats()
|
||||||
|
{
|
||||||
|
if (!$this->authService->isLoggedIn()) {
|
||||||
|
return ApiResponse::unauthorized();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Total certificates
|
||||||
|
$totalStmt = $this->db->query("SELECT COUNT(*) as total FROM certificates");
|
||||||
|
$total = $totalStmt->fetch()['total'];
|
||||||
|
|
||||||
|
// Active certificates
|
||||||
|
$activeStmt = $this->db->query("SELECT COUNT(*) as active FROM certificates WHERE is_revoked = FALSE");
|
||||||
|
$active = $activeStmt->fetch()['active'];
|
||||||
|
|
||||||
|
// Revoked certificates
|
||||||
|
$revokedStmt = $this->db->query("SELECT COUNT(*) as revoked FROM certificates WHERE is_revoked = TRUE");
|
||||||
|
$revoked = $revokedStmt->fetch()['revoked'];
|
||||||
|
|
||||||
|
// Certificates expiring in the next 30 days
|
||||||
|
$expiringSoonStmt = $this->db->prepare("
|
||||||
|
SELECT c.*, fp.name as perimeter_name
|
||||||
|
FROM certificates c
|
||||||
|
LEFT JOIN functional_perimeters fp ON c.functional_perimeter_id = fp.id
|
||||||
|
WHERE c.is_revoked = FALSE
|
||||||
|
AND c.expiration_date <= DATE_ADD(NOW(), INTERVAL 30 DAY)
|
||||||
|
ORDER BY c.expiration_date ASC
|
||||||
|
");
|
||||||
|
$expiringSoonStmt->execute();
|
||||||
|
$expiringSoon = $expiringSoonStmt->fetchAll();
|
||||||
|
|
||||||
|
$stats = [
|
||||||
|
'total' => $total,
|
||||||
|
'active' => $active,
|
||||||
|
'revoked' => $revoked,
|
||||||
|
'expiring_soon' => $expiringSoon
|
||||||
|
];
|
||||||
|
|
||||||
|
return ApiResponse::success($stats);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Télécharger un certificat
|
||||||
|
* GET /api/v1/certificates/download
|
||||||
|
*/
|
||||||
|
public function download()
|
||||||
|
{
|
||||||
|
if (!$this->authService->isLoggedIn()) {
|
||||||
|
return ApiResponse::unauthorized();
|
||||||
|
}
|
||||||
|
|
||||||
|
$type = $_GET['type'] ?? null;
|
||||||
|
$fileName = $_GET['file'] ?? null;
|
||||||
|
$perimeterName = $_GET['perimeter'] ?? null;
|
||||||
|
|
||||||
|
$userId = $this->authService->getUserId();
|
||||||
|
$ipAddress = $_SERVER['REMOTE_ADDR'];
|
||||||
|
|
||||||
|
$this->logService->log('info', "Download attempt: type='{$type}', file='{$fileName}', perimeter='{$perimeterName}'", $userId, $ipAddress);
|
||||||
|
|
||||||
|
if (empty($type) || empty($fileName)) {
|
||||||
|
return ApiResponse::badRequest('Missing download parameters');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (basename($fileName) !== $fileName || ($perimeterName && basename($perimeterName) !== $perimeterName)) {
|
||||||
|
return ApiResponse::badRequest('Invalid characters in file or perimeter name');
|
||||||
|
}
|
||||||
|
|
||||||
|
$filePath = $this->buildFilePath($type, $fileName, $perimeterName);
|
||||||
|
|
||||||
|
if (!$filePath) {
|
||||||
|
return ApiResponse::badRequest('Invalid certificate type or file');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!file_exists($filePath) || !is_readable($filePath)) {
|
||||||
|
return ApiResponse::notFound('File not found or not readable');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check permissions for private keys
|
||||||
|
if (str_ends_with($fileName, '.key.pem') && $this->authService->getUserRole() !== 'admin') {
|
||||||
|
return ApiResponse::forbidden('Unauthorized to download private keys');
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->logService->log('info', "File '{$filePath}' download initiated", $userId, $ipAddress);
|
||||||
|
|
||||||
|
// Set headers for file download
|
||||||
|
$mimeType = 'application/x-pem-file';
|
||||||
|
header('Content-Description: File Transfer');
|
||||||
|
header('Content-Type: ' . $mimeType);
|
||||||
|
header('Content-Disposition: attachment; filename="' . basename($filePath) . '"');
|
||||||
|
header('Expires: 0');
|
||||||
|
header('Cache-Control: must-revalidate');
|
||||||
|
header('Pragma: public');
|
||||||
|
header('Content-Length: ' . filesize($filePath));
|
||||||
|
|
||||||
|
if (ob_get_level()) {
|
||||||
|
ob_end_clean();
|
||||||
|
}
|
||||||
|
|
||||||
|
readfile($filePath);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function buildFilePath($type, $fileName, $perimeterName)
|
||||||
|
{
|
||||||
|
switch ($type) {
|
||||||
|
case 'root':
|
||||||
|
if ($fileName === 'ca.cert.pem') {
|
||||||
|
return ROOT_CA_PATH . '/certs/' . $fileName;
|
||||||
|
} elseif ($fileName === 'ca.key.pem') {
|
||||||
|
return ROOT_CA_PATH . '/private/' . $fileName;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'intermediate':
|
||||||
|
case 'simple':
|
||||||
|
if (empty($perimeterName)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (str_ends_with($fileName, '.key.pem')) {
|
||||||
|
return INTERMEDIATE_CA_PATH_BASE . '/' . $perimeterName . '/private/' . $fileName;
|
||||||
|
} else {
|
||||||
|
return INTERMEDIATE_CA_PATH_BASE . '/' . $perimeterName . '/certs/' . $fileName;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function revokeIntermediateCertificate($cert, $certificateId, $userId, $ipAddress)
|
||||||
|
{
|
||||||
|
$functionalPerimeterName = $cert['perimeter_name'];
|
||||||
|
$intermediateCertPath = "/opt/tls/intermediate/" . $functionalPerimeterName . "/certs/" . $cert['name'];
|
||||||
|
$rootCaConfigPath = "/opt/tls/root/openssl.cnf";
|
||||||
|
$rootCaCrlPath = "/opt/tls/root/crl/crl.pem";
|
||||||
|
|
||||||
|
$revokeCmd = sprintf(
|
||||||
|
"openssl ca -batch -config %s -revoke %s",
|
||||||
|
escapeshellarg($rootCaConfigPath),
|
||||||
|
escapeshellarg($intermediateCertPath)
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->logService->log('info', "Tentative de révocation du certificat intermédiaire '{$cert['name']}'", $userId, $ipAddress);
|
||||||
|
$outputRevoke = shell_exec($revokeCmd . ' 2>&1');
|
||||||
|
|
||||||
|
if (strpos($outputRevoke, "Data Base Updated") !== false || strpos($outputRevoke, "Successfully revoked certificate") !== false) {
|
||||||
|
$generateCrlCmd = sprintf(
|
||||||
|
"openssl ca -batch -config %s -gencrl -out %s",
|
||||||
|
escapeshellarg($rootCaConfigPath),
|
||||||
|
escapeshellarg($rootCaCrlPath)
|
||||||
|
);
|
||||||
|
|
||||||
|
$outputCrl = shell_exec($generateCrlCmd . ' 2>&1');
|
||||||
|
|
||||||
|
if ((strpos($outputCrl, "CRL Generated") !== false || strpos($outputCrl, "CRL generated") !== false) && file_exists($rootCaCrlPath)) {
|
||||||
|
$stmt_update = $this->db->prepare("UPDATE certificates SET is_revoked = TRUE, revoked_at = NOW() WHERE id = ?");
|
||||||
|
$stmt_update->execute([$certificateId]);
|
||||||
|
|
||||||
|
$this->logService->log('info', "Certificat intermédiaire '{$cert['name']}' révoqué et CRL mise à jour", $userId, $ipAddress);
|
||||||
|
return ApiResponse::success(null, 'Intermediate certificate revoked successfully');
|
||||||
|
} else {
|
||||||
|
$this->logService->log('error', "Échec de la mise à jour de la CRL: $outputCrl", $userId, $ipAddress);
|
||||||
|
return ApiResponse::serverError('Failed to update CRL');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
$this->logService->log('error', "Échec révocation certificat intermédiaire: $outputRevoke", $userId, $ipAddress);
|
||||||
|
return ApiResponse::serverError('Failed to revoke intermediate certificate');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function revokeSimpleCertificate($cert, $certificateId, $userId, $ipAddress)
|
||||||
|
{
|
||||||
|
$certBaseName = str_replace('.cert.pem', '.cert', $cert['name']);
|
||||||
|
$functionalPerimeterName = $cert['perimeter_name'];
|
||||||
|
|
||||||
|
$command = escapeshellcmd(SCRIPTS_PATH . '/revoke_cert.sh') . ' ' .
|
||||||
|
escapeshellarg($certBaseName) . ' ' .
|
||||||
|
escapeshellarg($functionalPerimeterName);
|
||||||
|
|
||||||
|
$this->logService->log('info', "Tentative de révocation du certificat simple '{$cert['name']}'", $userId, $ipAddress);
|
||||||
|
$output = shell_exec($command . ' 2>&1');
|
||||||
|
|
||||||
|
if (strpos($output, "Certificat '$certBaseName' révoqué avec succès.") !== false) {
|
||||||
|
$stmt_update = $this->db->prepare("UPDATE certificates SET is_revoked = TRUE, revoked_at = NOW() WHERE id = ?");
|
||||||
|
$stmt_update->execute([$certificateId]);
|
||||||
|
|
||||||
|
$this->logService->log('info', "Certificat simple '{$cert['name']}' révoqué", $userId, $ipAddress);
|
||||||
|
return ApiResponse::success(null, 'Certificate revoked successfully');
|
||||||
|
} else {
|
||||||
|
$this->logService->log('error', "Échec révocation certificat simple: $output", $userId, $ipAddress);
|
||||||
|
return ApiResponse::serverError('Failed to revoke certificate');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
92
app/src/Api/V1/Middleware/AuthMiddleware.php
Normal file
92
app/src/Api/V1/Middleware/AuthMiddleware.php
Normal file
@ -0,0 +1,92 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Api\V1\Middleware;
|
||||||
|
|
||||||
|
use App\Core\Database;
|
||||||
|
use App\Services\AuthService;
|
||||||
|
use App\Api\V1\Responses\ApiResponse;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Middleware d'authentification pour les routes API.
|
||||||
|
*/
|
||||||
|
class AuthMiddleware
|
||||||
|
{
|
||||||
|
private $authService;
|
||||||
|
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
$this->authService = new AuthService(Database::getInstance());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vérifie l'authentification pour les routes protégées.
|
||||||
|
*/
|
||||||
|
public function handle()
|
||||||
|
{
|
||||||
|
// Vérifier si l'utilisateur est connecté via session
|
||||||
|
if ($this->authService->isLoggedIn()) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vérifier le token Bearer (pour les appels API)
|
||||||
|
$authHeader = $_SERVER['HTTP_AUTHORIZATION'] ?? '';
|
||||||
|
if (preg_match('/Bearer\s+(.*)$/i', $authHeader, $matches)) {
|
||||||
|
$token = $matches[1];
|
||||||
|
if ($this->validateToken($token)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ApiResponse::unauthorized('Authentication required');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validation simple du token (à remplacer par JWT en production).
|
||||||
|
*/
|
||||||
|
private function validateToken($token)
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$decoded = base64_decode($token);
|
||||||
|
$parts = explode(':', $decoded);
|
||||||
|
|
||||||
|
if (count($parts) === 3) {
|
||||||
|
$userId = $parts[0];
|
||||||
|
$timestamp = $parts[1];
|
||||||
|
$signature = $parts[2];
|
||||||
|
|
||||||
|
// Vérifier la signature
|
||||||
|
$expectedSignature = base64_encode($userId . ':' . $timestamp . ':' . SESSION_SECRET);
|
||||||
|
if ($signature === SESSION_SECRET) {
|
||||||
|
// Vérifier que le token n'est pas trop ancien (24h)
|
||||||
|
if ((time() - $timestamp) < 86400) {
|
||||||
|
// Reconstituer la session utilisateur
|
||||||
|
$this->restoreUserSession($userId);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (Exception $e) {
|
||||||
|
// Token invalide
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Restaure la session utilisateur à partir de l'ID.
|
||||||
|
*/
|
||||||
|
private function restoreUserSession($userId)
|
||||||
|
{
|
||||||
|
$db = Database::getInstance();
|
||||||
|
$stmt = $db->prepare("SELECT id, username, role FROM users WHERE id = ?");
|
||||||
|
$stmt->execute([$userId]);
|
||||||
|
$user = $stmt->fetch();
|
||||||
|
|
||||||
|
if ($user) {
|
||||||
|
$_SESSION['user_id'] = $user['id'];
|
||||||
|
$_SESSION['username'] = $user['username'];
|
||||||
|
$_SESSION['role'] = $user['role'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
115
app/src/Api/V1/Responses/ApiResponse.php
Normal file
115
app/src/Api/V1/Responses/ApiResponse.php
Normal file
@ -0,0 +1,115 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Api\V1\Responses;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Classe utilitaire pour les réponses API standardisées.
|
||||||
|
*/
|
||||||
|
class ApiResponse
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Envoie une réponse de succès.
|
||||||
|
*/
|
||||||
|
public static function success($data = null, $message = 'Success', $statusCode = 200)
|
||||||
|
{
|
||||||
|
http_response_code($statusCode);
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
|
||||||
|
$response = [
|
||||||
|
'success' => true,
|
||||||
|
'message' => $message,
|
||||||
|
];
|
||||||
|
|
||||||
|
if ($data !== null) {
|
||||||
|
$response['data'] = $data;
|
||||||
|
}
|
||||||
|
|
||||||
|
echo json_encode($response, JSON_UNESCAPED_UNICODE);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Envoie une réponse de création réussie.
|
||||||
|
*/
|
||||||
|
public static function created($data = null, $message = 'Created successfully')
|
||||||
|
{
|
||||||
|
return self::success($data, $message, 201);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Envoie une réponse d'erreur.
|
||||||
|
*/
|
||||||
|
public static function error($message = 'An error occurred', $statusCode = 400, $errors = null)
|
||||||
|
{
|
||||||
|
http_response_code($statusCode);
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
|
||||||
|
$response = [
|
||||||
|
'success' => false,
|
||||||
|
'message' => $message,
|
||||||
|
];
|
||||||
|
|
||||||
|
if ($errors !== null) {
|
||||||
|
$response['errors'] = $errors;
|
||||||
|
}
|
||||||
|
|
||||||
|
echo json_encode($response, JSON_UNESCAPED_UNICODE);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Réponse d'erreur 400 - Bad Request.
|
||||||
|
*/
|
||||||
|
public static function badRequest($message = 'Bad Request', $errors = null)
|
||||||
|
{
|
||||||
|
return self::error($message, 400, $errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Réponse d'erreur 401 - Unauthorized.
|
||||||
|
*/
|
||||||
|
public static function unauthorized($message = 'Unauthorized')
|
||||||
|
{
|
||||||
|
return self::error($message, 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Réponse d'erreur 403 - Forbidden.
|
||||||
|
*/
|
||||||
|
public static function forbidden($message = 'Forbidden')
|
||||||
|
{
|
||||||
|
return self::error($message, 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Réponse d'erreur 404 - Not Found.
|
||||||
|
*/
|
||||||
|
public static function notFound($message = 'Not Found')
|
||||||
|
{
|
||||||
|
return self::error($message, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Réponse d'erreur 405 - Method Not Allowed.
|
||||||
|
*/
|
||||||
|
public static function methodNotAllowed($message = 'Method Not Allowed')
|
||||||
|
{
|
||||||
|
return self::error($message, 405);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Réponse d'erreur 422 - Unprocessable Entity.
|
||||||
|
*/
|
||||||
|
public static function unprocessableEntity($message = 'Validation failed', $errors = null)
|
||||||
|
{
|
||||||
|
return self::error($message, 422, $errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Réponse d'erreur 500 - Internal Server Error.
|
||||||
|
*/
|
||||||
|
public static function serverError($message = 'Internal Server Error', $errors = null)
|
||||||
|
{
|
||||||
|
return self::error($message, 500, $errors);
|
||||||
|
}
|
||||||
|
}
|
113
app/src/Api/V1/Router.php
Normal file
113
app/src/Api/V1/Router.php
Normal file
@ -0,0 +1,113 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Api\V1;
|
||||||
|
|
||||||
|
use App\Api\V1\Middleware\AuthMiddleware;
|
||||||
|
use App\Api\V1\Responses\ApiResponse;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Routeur pour les API V1.
|
||||||
|
*/
|
||||||
|
class Router
|
||||||
|
{
|
||||||
|
private $routes = [];
|
||||||
|
private $authMiddleware;
|
||||||
|
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
$this->authMiddleware = new AuthMiddleware();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enregistre une route.
|
||||||
|
*/
|
||||||
|
public function addRoute($method, $path, $controller, $action, $requiresAuth = false)
|
||||||
|
{
|
||||||
|
$this->routes[] = [
|
||||||
|
'method' => $method,
|
||||||
|
'path' => $path,
|
||||||
|
'controller' => $controller,
|
||||||
|
'action' => $action,
|
||||||
|
'requiresAuth' => $requiresAuth
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dispatche la requête vers le bon contrôleur.
|
||||||
|
*/
|
||||||
|
public function dispatch()
|
||||||
|
{
|
||||||
|
$requestMethod = $_SERVER['REQUEST_METHOD'];
|
||||||
|
$requestUri = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);
|
||||||
|
|
||||||
|
// Retirer le préfixe /api/v1
|
||||||
|
$requestUri = preg_replace('#^/api/v1#', '', $requestUri);
|
||||||
|
if (empty($requestUri)) {
|
||||||
|
$requestUri = '/';
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($this->routes as $route) {
|
||||||
|
if ($this->matchRoute($route, $requestMethod, $requestUri)) {
|
||||||
|
// Vérifier l'authentification si nécessaire
|
||||||
|
if ($route['requiresAuth'] && !$this->authMiddleware->handle()) {
|
||||||
|
return; // Le middleware a déjà envoyé la réponse
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extraire les paramètres de l'URL
|
||||||
|
$params = $this->extractParams($route['path'], $requestUri);
|
||||||
|
|
||||||
|
// Instancier le contrôleur et appeler l'action
|
||||||
|
$controllerClass = "App\\Api\\V1\\Controllers\\" . $route['controller'];
|
||||||
|
|
||||||
|
if (class_exists($controllerClass)) {
|
||||||
|
$controller = new $controllerClass();
|
||||||
|
$action = $route['action'];
|
||||||
|
|
||||||
|
if (method_exists($controller, $action)) {
|
||||||
|
// Appeler l'action avec les paramètres
|
||||||
|
call_user_func_array([$controller, $action], $params);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ApiResponse::serverError('Controller or action not found');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ApiResponse::notFound('API endpoint not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vérifie si une route correspond à la requête.
|
||||||
|
*/
|
||||||
|
private function matchRoute($route, $method, $uri)
|
||||||
|
{
|
||||||
|
if ($route['method'] !== $method) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convertir le pattern de route en regex
|
||||||
|
$pattern = preg_replace('/\{([^}]+)\}/', '([^/]+)', $route['path']);
|
||||||
|
$pattern = '#^' . $pattern . '$#';
|
||||||
|
|
||||||
|
return preg_match($pattern, $uri);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extrait les paramètres de l'URL.
|
||||||
|
*/
|
||||||
|
private function extractParams($routePath, $uri)
|
||||||
|
{
|
||||||
|
$pattern = preg_replace('/\{([^}]+)\}/', '([^/]+)', $routePath);
|
||||||
|
$pattern = '#^' . $pattern . '$#';
|
||||||
|
|
||||||
|
if (preg_match($pattern, $uri, $matches)) {
|
||||||
|
// Retirer le premier élément (correspondance complète)
|
||||||
|
array_shift($matches);
|
||||||
|
return $matches;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
86
docker-compose.dev.yml
Normal file
86
docker-compose.dev.yml
Normal file
@ -0,0 +1,86 @@
|
|||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
nginx:
|
||||||
|
image: nginx:latest
|
||||||
|
container_name: cert-gestion-nginx-dev
|
||||||
|
ports:
|
||||||
|
- "980:80"
|
||||||
|
- "9443:443"
|
||||||
|
volumes:
|
||||||
|
- ./nginx:/etc/nginx/conf.d:ro
|
||||||
|
- ./app:/var/www/html:ro
|
||||||
|
- ./tls:/opt/tls:rw
|
||||||
|
- ./storage/nginx_logs:/var/log/nginx:rw
|
||||||
|
depends_on:
|
||||||
|
- php-fpm
|
||||||
|
networks:
|
||||||
|
- cert-gestion-network
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
php-fpm:
|
||||||
|
build:
|
||||||
|
context: ./php
|
||||||
|
dockerfile: Dockerfile.dev
|
||||||
|
container_name: cert-gestion-php-fpm-dev
|
||||||
|
volumes:
|
||||||
|
- ./app:/var/www/html:rw
|
||||||
|
- ./scripts:/opt/scripts:rw
|
||||||
|
- ./tls:/opt/tls:rw
|
||||||
|
- ./storage/php_logs:/var/log/app:rw
|
||||||
|
environment:
|
||||||
|
DB_HOST: mysql
|
||||||
|
DB_NAME: cert_gestion
|
||||||
|
DB_USER: user
|
||||||
|
DB_PASSWORD: password_secret
|
||||||
|
APP_ENV: development
|
||||||
|
depends_on:
|
||||||
|
- mysql
|
||||||
|
networks:
|
||||||
|
- cert-gestion-network
|
||||||
|
restart: unless-stopped
|
||||||
|
command: >
|
||||||
|
bash -c "chown -R www-data:www-data /var/www/html /var/log/app &&
|
||||||
|
chmod -R 775 /var/www/html /var/log/app &&
|
||||||
|
chmod +x /opt/scripts/*.sh &&
|
||||||
|
php-fpm"
|
||||||
|
|
||||||
|
frontend:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile.frontend.dev
|
||||||
|
container_name: cert-gestion-frontend-dev
|
||||||
|
ports:
|
||||||
|
- "3000:3000"
|
||||||
|
volumes:
|
||||||
|
- .:/app
|
||||||
|
- /app/node_modules
|
||||||
|
environment:
|
||||||
|
- VITE_API_BASE_URL=http://localhost:980/api/v1
|
||||||
|
networks:
|
||||||
|
- cert-gestion-network
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
mysql:
|
||||||
|
image: mysql:8.0
|
||||||
|
container_name: cert-gestion-mysql-dev
|
||||||
|
environment:
|
||||||
|
MYSQL_ROOT_PASSWORD: root_password_secret
|
||||||
|
MYSQL_DATABASE: cert_gestion
|
||||||
|
MYSQL_USER: user
|
||||||
|
MYSQL_PASSWORD: password_secret
|
||||||
|
volumes:
|
||||||
|
- mysql_data_dev:/var/lib/mysql
|
||||||
|
- ./mysql/init.sql:/docker-entrypoint-initdb.d/init.sql:ro
|
||||||
|
ports:
|
||||||
|
- "3307:3306"
|
||||||
|
networks:
|
||||||
|
- cert-gestion-network
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
mysql_data_dev:
|
||||||
|
|
||||||
|
networks:
|
||||||
|
cert-gestion-network:
|
||||||
|
driver: bridge
|
198
docs/API.md
Normal file
198
docs/API.md
Normal file
@ -0,0 +1,198 @@
|
|||||||
|
# API Documentation
|
||||||
|
|
||||||
|
## Base URL
|
||||||
|
```
|
||||||
|
http://localhost:980/api/v1
|
||||||
|
```
|
||||||
|
|
||||||
|
## Authentication
|
||||||
|
|
||||||
|
La plupart des endpoints nécessitent une authentification. Après connexion, incluez le token dans l'en-tête Authorization :
|
||||||
|
|
||||||
|
```
|
||||||
|
Authorization: Bearer <token>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Endpoints
|
||||||
|
|
||||||
|
### Authentication
|
||||||
|
|
||||||
|
#### POST /auth/login
|
||||||
|
Connexion utilisateur.
|
||||||
|
|
||||||
|
**Body:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"username": "admin",
|
||||||
|
"password": "password"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"message": "Login successful",
|
||||||
|
"data": {
|
||||||
|
"id": 1,
|
||||||
|
"username": "admin",
|
||||||
|
"role": "admin",
|
||||||
|
"token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9..."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### POST /auth/logout
|
||||||
|
Déconnexion utilisateur.
|
||||||
|
|
||||||
|
#### GET /auth/me
|
||||||
|
Informations de l'utilisateur connecté.
|
||||||
|
|
||||||
|
### Certificates
|
||||||
|
|
||||||
|
#### GET /certificates
|
||||||
|
Liste paginée des certificats.
|
||||||
|
|
||||||
|
**Query Parameters:**
|
||||||
|
- `page` (int): Page à récupérer (défaut: 1)
|
||||||
|
- `per_page` (int): Nombre d'éléments par page (défaut: 50, max: 100)
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"data": {
|
||||||
|
"data": [...],
|
||||||
|
"current_page": 1,
|
||||||
|
"per_page": 50,
|
||||||
|
"total": 42,
|
||||||
|
"last_page": 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### POST /certificates
|
||||||
|
Créer un nouveau certificat.
|
||||||
|
|
||||||
|
**Body:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"subdomain_name": "www",
|
||||||
|
"functional_perimeter_id": 1
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### POST /certificates/{id}/revoke
|
||||||
|
Révoquer un certificat.
|
||||||
|
|
||||||
|
#### GET /certificates/download
|
||||||
|
Télécharger un certificat.
|
||||||
|
|
||||||
|
**Query Parameters:**
|
||||||
|
- `type`: Type de certificat (`root`, `intermediate`, `simple`)
|
||||||
|
- `file`: Nom du fichier
|
||||||
|
- `perimeter`: Nom du périmètre (requis pour intermediate/simple)
|
||||||
|
|
||||||
|
#### GET /certificates/stats
|
||||||
|
Statistiques des certificats.
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"data": {
|
||||||
|
"total": 42,
|
||||||
|
"active": 38,
|
||||||
|
"revoked": 4,
|
||||||
|
"expiring_soon": [...]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Functional Perimeters
|
||||||
|
|
||||||
|
#### GET /perimeters
|
||||||
|
Liste des périmètres fonctionnels.
|
||||||
|
|
||||||
|
#### POST /perimeters
|
||||||
|
Créer un nouveau périmètre.
|
||||||
|
|
||||||
|
**Body:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "Finance",
|
||||||
|
"intermediate_passphrase": "optional_passphrase"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Users
|
||||||
|
|
||||||
|
#### GET /users
|
||||||
|
Liste des utilisateurs (Admin uniquement).
|
||||||
|
|
||||||
|
#### POST /users
|
||||||
|
Créer un nouvel utilisateur (Admin uniquement).
|
||||||
|
|
||||||
|
**Body:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"username": "newuser",
|
||||||
|
"password": "securepassword",
|
||||||
|
"role": "user"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### DELETE /users/{id}
|
||||||
|
Supprimer un utilisateur (Admin uniquement).
|
||||||
|
|
||||||
|
#### PUT /users/{id}/role
|
||||||
|
Modifier le rôle d'un utilisateur (Admin uniquement).
|
||||||
|
|
||||||
|
**Body:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"role": "admin"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### PUT /users/{id}/password
|
||||||
|
Modifier le mot de passe d'un utilisateur (Admin uniquement).
|
||||||
|
|
||||||
|
**Body:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"new_password": "newpassword",
|
||||||
|
"confirm_password": "newpassword"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Dashboard
|
||||||
|
|
||||||
|
#### GET /dashboard/stats
|
||||||
|
Statistiques générales du dashboard.
|
||||||
|
|
||||||
|
## Error Responses
|
||||||
|
|
||||||
|
Toutes les erreurs suivent ce format :
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": false,
|
||||||
|
"message": "Error description",
|
||||||
|
"errors": {
|
||||||
|
"field": ["Validation error message"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Status Codes
|
||||||
|
|
||||||
|
- `200`: OK
|
||||||
|
- `201`: Created
|
||||||
|
- `400`: Bad Request
|
||||||
|
- `401`: Unauthorized
|
||||||
|
- `403`: Forbidden
|
||||||
|
- `404`: Not Found
|
||||||
|
- `405`: Method Not Allowed
|
||||||
|
- `422`: Unprocessable Entity
|
||||||
|
- `500`: Internal Server Error
|
14
index.html
Normal file
14
index.html
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/certificate-icon.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Certificate Management System</title>
|
||||||
|
<meta name="description" content="Modern certificate management system for PKI infrastructure" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
17
nginx/api.conf
Normal file
17
nginx/api.conf
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
# Configuration Nginx pour les routes API
|
||||||
|
location /api/ {
|
||||||
|
# Retirer le préfixe /api et rediriger vers api.php
|
||||||
|
rewrite ^/api/(.*)$ /api.php?path=$1 last;
|
||||||
|
}
|
||||||
|
|
||||||
|
location = /api.php {
|
||||||
|
fastcgi_pass php-fpm:9000;
|
||||||
|
fastcgi_index api.php;
|
||||||
|
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
|
||||||
|
include fastcgi_params;
|
||||||
|
|
||||||
|
# Headers pour les API
|
||||||
|
add_header 'Access-Control-Allow-Origin' '*' always;
|
||||||
|
add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, DELETE, OPTIONS' always;
|
||||||
|
add_header 'Access-Control-Allow-Headers' 'Content-Type, Authorization, X-Requested-With' always;
|
||||||
|
}
|
7448
package-lock.json
generated
Normal file
7448
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
56
package.json
Normal file
56
package.json
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
{
|
||||||
|
"name": "certificate-management-frontend",
|
||||||
|
"version": "2.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc && vite build",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
|
||||||
|
"lint:fix": "eslint . --ext ts,tsx --fix",
|
||||||
|
"format": "prettier --write \"src/**/*.{ts,tsx,css,json}\"",
|
||||||
|
"type-check": "tsc --noEmit",
|
||||||
|
"test": "vitest",
|
||||||
|
"test:ui": "vitest --ui",
|
||||||
|
"test:coverage": "vitest --coverage"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"react": "^18.2.0",
|
||||||
|
"react-dom": "^18.2.0",
|
||||||
|
"react-router-dom": "^6.20.1",
|
||||||
|
"react-query": "^3.39.3",
|
||||||
|
"axios": "^1.6.2",
|
||||||
|
"react-hook-form": "^7.48.2",
|
||||||
|
"react-hot-toast": "^2.4.1",
|
||||||
|
"lucide-react": "^0.294.0",
|
||||||
|
"clsx": "^2.0.0",
|
||||||
|
"tailwind-merge": "^2.1.0",
|
||||||
|
"date-fns": "^2.30.0",
|
||||||
|
"react-i18next": "^13.5.0",
|
||||||
|
"i18next": "^23.7.6",
|
||||||
|
"zustand": "^4.4.7",
|
||||||
|
"recharts": "^2.8.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/react": "^18.2.37",
|
||||||
|
"@types/react-dom": "^18.2.15",
|
||||||
|
"@typescript-eslint/eslint-plugin": "^6.10.0",
|
||||||
|
"@typescript-eslint/parser": "^6.10.0",
|
||||||
|
"@vitejs/plugin-react": "^4.1.1",
|
||||||
|
"eslint": "^8.53.0",
|
||||||
|
"eslint-plugin-react-hooks": "^4.6.0",
|
||||||
|
"eslint-plugin-react-refresh": "^0.4.4",
|
||||||
|
"prettier": "^3.1.0",
|
||||||
|
"tailwindcss": "^3.3.6",
|
||||||
|
"autoprefixer": "^10.4.16",
|
||||||
|
"postcss": "^8.4.32",
|
||||||
|
"typescript": "^5.2.2",
|
||||||
|
"vite": "^5.0.0",
|
||||||
|
"vitest": "^1.0.0",
|
||||||
|
"@vitest/ui": "^1.0.0",
|
||||||
|
"@vitest/coverage-v8": "^1.0.0",
|
||||||
|
"@testing-library/react": "^14.1.2",
|
||||||
|
"@testing-library/jest-dom": "^6.1.5",
|
||||||
|
"@testing-library/user-event": "^14.5.1"
|
||||||
|
}
|
||||||
|
}
|
39
php/Dockerfile.dev
Normal file
39
php/Dockerfile.dev
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
FROM php:8.3-fpm
|
||||||
|
|
||||||
|
# Installer les dépendances système
|
||||||
|
RUN apt-get update && apt-get install -y \
|
||||||
|
build-essential \
|
||||||
|
libpng-dev \
|
||||||
|
libjpeg62-turbo-dev \
|
||||||
|
libfreetype6-dev \
|
||||||
|
locales \
|
||||||
|
zip \
|
||||||
|
jpegoptim optipng pngquant gifsicle \
|
||||||
|
vim \
|
||||||
|
unzip \
|
||||||
|
git \
|
||||||
|
curl \
|
||||||
|
libzip-dev \
|
||||||
|
libonig-dev \
|
||||||
|
mysql-common \
|
||||||
|
libldap2-dev \
|
||||||
|
libicu-dev \
|
||||||
|
openssl \
|
||||||
|
ca-certificates
|
||||||
|
|
||||||
|
# Installer les extensions PHP
|
||||||
|
RUN docker-php-ext-install pdo_mysql opcache zip intl
|
||||||
|
|
||||||
|
# Configuration PHP pour le développement
|
||||||
|
COPY php.dev.ini /usr/local/etc/php/conf.d/40-custom.ini
|
||||||
|
|
||||||
|
# Installer Composer
|
||||||
|
COPY --from=composer:latest /usr/bin/composer /usr/bin/composer
|
||||||
|
|
||||||
|
# Installer Xdebug pour le développement
|
||||||
|
RUN pecl install xdebug \
|
||||||
|
&& docker-php-ext-enable xdebug
|
||||||
|
|
||||||
|
WORKDIR /var/www/html
|
||||||
|
|
||||||
|
EXPOSE 9000
|
32
php/php.dev.ini
Normal file
32
php/php.dev.ini
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
; Configuration PHP pour le développement
|
||||||
|
|
||||||
|
; Affichage des erreurs activé
|
||||||
|
display_errors = On
|
||||||
|
display_startup_errors = On
|
||||||
|
error_reporting = E_ALL
|
||||||
|
|
||||||
|
; Log des erreurs
|
||||||
|
log_errors = On
|
||||||
|
error_log = /var/log/app/php_error.log
|
||||||
|
|
||||||
|
; Temps d'exécution et mémoire
|
||||||
|
max_execution_time = 300
|
||||||
|
memory_limit = 512M
|
||||||
|
|
||||||
|
; Upload
|
||||||
|
upload_max_filesize = 128M
|
||||||
|
post_max_size = 128M
|
||||||
|
|
||||||
|
; Timezone
|
||||||
|
date.timezone = Europe/Paris
|
||||||
|
|
||||||
|
; Opcache (optimisé pour le développement)
|
||||||
|
opcache.enable = 1
|
||||||
|
opcache.revalidate_freq = 0
|
||||||
|
opcache.validate_timestamps = 1
|
||||||
|
|
||||||
|
; Xdebug configuration
|
||||||
|
xdebug.mode = develop,debug
|
||||||
|
xdebug.start_with_request = yes
|
||||||
|
xdebug.client_host = host.docker.internal
|
||||||
|
xdebug.client_port = 9003
|
93
src/App.tsx
Normal file
93
src/App.tsx
Normal file
@ -0,0 +1,93 @@
|
|||||||
|
import { useEffect } from 'react'
|
||||||
|
import { Routes, Route, Navigate } from 'react-router-dom'
|
||||||
|
import { useAuth } from './hooks/useAuth'
|
||||||
|
import { useTheme } from './hooks/useTheme'
|
||||||
|
|
||||||
|
// Layout Components
|
||||||
|
import { PublicLayout } from './components/layouts/PublicLayout'
|
||||||
|
import { ProtectedLayout } from './components/layouts/ProtectedLayout'
|
||||||
|
|
||||||
|
// Pages
|
||||||
|
import { LoginPage } from './pages/auth/LoginPage'
|
||||||
|
import { DashboardPage } from './pages/dashboard/DashboardPage'
|
||||||
|
import { CertificatesPage } from './pages/certificates/CertificatesPage'
|
||||||
|
import { CreateCertificatePage } from './pages/certificates/CreateCertificatePage'
|
||||||
|
import { PerimetersPage } from './pages/perimeters/PerimetersPage'
|
||||||
|
import { CreatePerimeterPage } from './pages/perimeters/CreatePerimeterPage'
|
||||||
|
import { UsersPage } from './pages/users/UsersPage'
|
||||||
|
import { CreateUserPage } from './pages/users/CreateUserPage'
|
||||||
|
import { EditUserPasswordPage } from './pages/users/EditUserPasswordPage'
|
||||||
|
import { NotFoundPage } from './pages/NotFoundPage'
|
||||||
|
|
||||||
|
// Components
|
||||||
|
import { LoadingSpinner } from './components/ui/LoadingSpinner'
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
const { isAuthenticated, isLoading } = useAuth()
|
||||||
|
const { theme } = useTheme()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Apply theme to document
|
||||||
|
if (theme === 'dark') {
|
||||||
|
document.documentElement.classList.add('dark')
|
||||||
|
} else {
|
||||||
|
document.documentElement.classList.remove('dark')
|
||||||
|
}
|
||||||
|
}, [theme])
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center">
|
||||||
|
<LoadingSpinner size="lg" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Routes>
|
||||||
|
{/* Public Routes */}
|
||||||
|
<Route path="/login" element={
|
||||||
|
!isAuthenticated ? (
|
||||||
|
<PublicLayout>
|
||||||
|
<LoginPage />
|
||||||
|
</PublicLayout>
|
||||||
|
) : (
|
||||||
|
<Navigate to="/dashboard" replace />
|
||||||
|
)
|
||||||
|
} />
|
||||||
|
|
||||||
|
{/* Protected Routes */}
|
||||||
|
<Route path="/" element={
|
||||||
|
isAuthenticated ? (
|
||||||
|
<ProtectedLayout />
|
||||||
|
) : (
|
||||||
|
<Navigate to="/login" replace />
|
||||||
|
)
|
||||||
|
}>
|
||||||
|
<Route index element={<Navigate to="/dashboard" replace />} />
|
||||||
|
<Route path="dashboard" element={<DashboardPage />} />
|
||||||
|
|
||||||
|
<Route path="certificates">
|
||||||
|
<Route index element={<CertificatesPage />} />
|
||||||
|
<Route path="create" element={<CreateCertificatePage />} />
|
||||||
|
</Route>
|
||||||
|
|
||||||
|
<Route path="perimeters">
|
||||||
|
<Route index element={<PerimetersPage />} />
|
||||||
|
<Route path="create" element={<CreatePerimeterPage />} />
|
||||||
|
</Route>
|
||||||
|
|
||||||
|
<Route path="users">
|
||||||
|
<Route index element={<UsersPage />} />
|
||||||
|
<Route path="create" element={<CreateUserPage />} />
|
||||||
|
<Route path=":id/edit-password" element={<EditUserPasswordPage />} />
|
||||||
|
</Route>
|
||||||
|
</Route>
|
||||||
|
|
||||||
|
{/* 404 */}
|
||||||
|
<Route path="*" element={<NotFoundPage />} />
|
||||||
|
</Routes>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App
|
68
src/components/ui/Button.tsx
Normal file
68
src/components/ui/Button.tsx
Normal file
@ -0,0 +1,68 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import { cn } from '../../lib/utils'
|
||||||
|
import { LoadingSpinner } from './LoadingSpinner'
|
||||||
|
|
||||||
|
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||||
|
variant?: 'primary' | 'secondary' | 'destructive' | 'outline' | 'ghost'
|
||||||
|
size?: 'sm' | 'md' | 'lg'
|
||||||
|
loading?: boolean
|
||||||
|
leftIcon?: React.ReactNode
|
||||||
|
rightIcon?: React.ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||||
|
({
|
||||||
|
className,
|
||||||
|
variant = 'primary',
|
||||||
|
size = 'md',
|
||||||
|
loading = false,
|
||||||
|
leftIcon,
|
||||||
|
rightIcon,
|
||||||
|
children,
|
||||||
|
disabled,
|
||||||
|
...props
|
||||||
|
}, ref) => {
|
||||||
|
const variants = {
|
||||||
|
primary: 'btn-primary',
|
||||||
|
secondary: 'btn-secondary',
|
||||||
|
destructive: 'btn-destructive',
|
||||||
|
outline: 'btn-outline',
|
||||||
|
ghost: 'btn-ghost',
|
||||||
|
}
|
||||||
|
|
||||||
|
const sizes = {
|
||||||
|
sm: 'btn-sm',
|
||||||
|
md: '',
|
||||||
|
lg: 'btn-lg',
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
className={cn(
|
||||||
|
'btn',
|
||||||
|
variants[variant],
|
||||||
|
sizes[size],
|
||||||
|
loading && 'opacity-70 cursor-not-allowed',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
ref={ref}
|
||||||
|
disabled={disabled || loading}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<LoadingSpinner size="sm" className="mr-2" />
|
||||||
|
) : leftIcon ? (
|
||||||
|
<span className="mr-2">{leftIcon}</span>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{children}
|
||||||
|
|
||||||
|
{rightIcon && !loading && (
|
||||||
|
<span className="ml-2">{rightIcon}</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
Button.displayName = 'Button'
|
32
src/components/ui/LoadingSpinner.tsx
Normal file
32
src/components/ui/LoadingSpinner.tsx
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import { cn } from '../../lib/utils'
|
||||||
|
|
||||||
|
interface LoadingSpinnerProps {
|
||||||
|
size?: 'sm' | 'md' | 'lg'
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const LoadingSpinner: React.FC<LoadingSpinnerProps> = ({
|
||||||
|
size = 'md',
|
||||||
|
className
|
||||||
|
}) => {
|
||||||
|
const sizes = {
|
||||||
|
sm: 'w-4 h-4',
|
||||||
|
md: 'w-6 h-6',
|
||||||
|
lg: 'w-8 h-8',
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'animate-spin rounded-full border-2 border-current border-t-transparent',
|
||||||
|
sizes[size],
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
role="status"
|
||||||
|
aria-label="Loading"
|
||||||
|
>
|
||||||
|
<span className="sr-only">Loading...</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
75
src/hooks/useAuth.ts
Normal file
75
src/hooks/useAuth.ts
Normal file
@ -0,0 +1,75 @@
|
|||||||
|
import { create } from 'zustand'
|
||||||
|
import { persist } from 'zustand/middleware'
|
||||||
|
import { AuthUser, LoginCredentials } from '../types'
|
||||||
|
import { authApi } from '../services/api'
|
||||||
|
import toast from 'react-hot-toast'
|
||||||
|
|
||||||
|
interface AuthState {
|
||||||
|
user: AuthUser | null
|
||||||
|
isAuthenticated: boolean
|
||||||
|
isLoading: boolean
|
||||||
|
login: (credentials: LoginCredentials) => Promise<boolean>
|
||||||
|
logout: () => void
|
||||||
|
updateUser: (user: Partial<AuthUser>) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useAuth = create<AuthState>()(
|
||||||
|
persist(
|
||||||
|
(set, get) => ({
|
||||||
|
user: null,
|
||||||
|
isAuthenticated: false,
|
||||||
|
isLoading: false,
|
||||||
|
|
||||||
|
login: async (credentials: LoginCredentials) => {
|
||||||
|
set({ isLoading: true })
|
||||||
|
try {
|
||||||
|
const response = await authApi.login(credentials)
|
||||||
|
if (response.success && response.data) {
|
||||||
|
set({
|
||||||
|
user: response.data,
|
||||||
|
isAuthenticated: true,
|
||||||
|
isLoading: false,
|
||||||
|
})
|
||||||
|
toast.success('Successfully logged in!')
|
||||||
|
return true
|
||||||
|
} else {
|
||||||
|
toast.error(response.message || 'Login failed')
|
||||||
|
set({ isLoading: false })
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Login error:', error)
|
||||||
|
toast.error('Login failed. Please try again.')
|
||||||
|
set({ isLoading: false })
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
logout: () => {
|
||||||
|
authApi.logout().catch(console.error)
|
||||||
|
set({
|
||||||
|
user: null,
|
||||||
|
isAuthenticated: false,
|
||||||
|
isLoading: false,
|
||||||
|
})
|
||||||
|
toast.success('Successfully logged out!')
|
||||||
|
},
|
||||||
|
|
||||||
|
updateUser: (userData: Partial<AuthUser>) => {
|
||||||
|
const currentUser = get().user
|
||||||
|
if (currentUser) {
|
||||||
|
set({
|
||||||
|
user: { ...currentUser, ...userData },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
name: 'auth-storage',
|
||||||
|
partialize: (state) => ({
|
||||||
|
user: state.user,
|
||||||
|
isAuthenticated: state.isAuthenticated,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
29
src/hooks/useTheme.ts
Normal file
29
src/hooks/useTheme.ts
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
import { create } from 'zustand'
|
||||||
|
import { persist } from 'zustand/middleware'
|
||||||
|
import { Theme } from '../types'
|
||||||
|
|
||||||
|
interface ThemeState {
|
||||||
|
theme: Theme
|
||||||
|
setTheme: (theme: Theme) => void
|
||||||
|
toggleTheme: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useTheme = create<ThemeState>()(
|
||||||
|
persist(
|
||||||
|
(set, get) => ({
|
||||||
|
theme: 'light',
|
||||||
|
|
||||||
|
setTheme: (theme: Theme) => {
|
||||||
|
set({ theme })
|
||||||
|
},
|
||||||
|
|
||||||
|
toggleTheme: () => {
|
||||||
|
const currentTheme = get().theme
|
||||||
|
set({ theme: currentTheme === 'light' ? 'dark' : 'light' })
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
name: 'theme-storage',
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
64
src/lib/utils.ts
Normal file
64
src/lib/utils.ts
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
import { type ClassValue, clsx } from 'clsx'
|
||||||
|
import { twMerge } from 'tailwind-merge'
|
||||||
|
|
||||||
|
export function cn(...inputs: ClassValue[]) {
|
||||||
|
return twMerge(clsx(inputs))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatDate(date: string | Date, options?: Intl.DateTimeFormatOptions): string {
|
||||||
|
const dateObject = typeof date === 'string' ? new Date(date) : date
|
||||||
|
|
||||||
|
return new Intl.DateTimeFormat('en-US', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
...options,
|
||||||
|
}).format(dateObject)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatDateTime(date: string | Date): string {
|
||||||
|
return formatDate(date, {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function downloadBlob(blob: Blob, filename: string): void {
|
||||||
|
const url = window.URL.createObjectURL(blob)
|
||||||
|
const link = document.createElement('a')
|
||||||
|
link.href = url
|
||||||
|
link.download = filename
|
||||||
|
document.body.appendChild(link)
|
||||||
|
link.click()
|
||||||
|
document.body.removeChild(link)
|
||||||
|
window.URL.revokeObjectURL(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isValidEmail(email: string): boolean {
|
||||||
|
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
||||||
|
return emailRegex.test(email)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function capitalizeFirst(str: string): string {
|
||||||
|
return str.charAt(0).toUpperCase() + str.slice(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function debounce<T extends (...args: any[]) => any>(
|
||||||
|
func: T,
|
||||||
|
wait: number
|
||||||
|
): (...args: Parameters<T>) => void {
|
||||||
|
let timeout: NodeJS.Timeout | null = null
|
||||||
|
|
||||||
|
return (...args: Parameters<T>) => {
|
||||||
|
if (timeout) clearTimeout(timeout)
|
||||||
|
timeout = setTimeout(() => func(...args), wait)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function truncate(str: string, length: number): string {
|
||||||
|
if (str.length <= length) return str
|
||||||
|
return str.slice(0, length) + '...'
|
||||||
|
}
|
50
src/main.tsx
Normal file
50
src/main.tsx
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import ReactDOM from 'react-dom/client'
|
||||||
|
import { QueryClient, QueryClientProvider } from 'react-query'
|
||||||
|
import { BrowserRouter } from 'react-router-dom'
|
||||||
|
import { Toaster } from 'react-hot-toast'
|
||||||
|
|
||||||
|
import App from './App'
|
||||||
|
import { AuthProvider } from './contexts/AuthContext'
|
||||||
|
import { ThemeProvider } from './contexts/ThemeContext'
|
||||||
|
import { I18nProvider } from './contexts/I18nContext'
|
||||||
|
|
||||||
|
import './styles/globals.css'
|
||||||
|
import './i18n/config'
|
||||||
|
|
||||||
|
const queryClient = new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: {
|
||||||
|
retry: 1,
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<BrowserRouter>
|
||||||
|
<I18nProvider>
|
||||||
|
<ThemeProvider>
|
||||||
|
<AuthProvider>
|
||||||
|
<App />
|
||||||
|
<Toaster
|
||||||
|
position="top-right"
|
||||||
|
toastOptions={{
|
||||||
|
duration: 4000,
|
||||||
|
style: {
|
||||||
|
background: 'hsl(var(--card))',
|
||||||
|
color: 'hsl(var(--card-foreground))',
|
||||||
|
border: '1px solid hsl(var(--border))',
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</AuthProvider>
|
||||||
|
</ThemeProvider>
|
||||||
|
</I18nProvider>
|
||||||
|
</BrowserRouter>
|
||||||
|
</QueryClientProvider>
|
||||||
|
</React.StrictMode>,
|
||||||
|
)
|
19
src/services/api/auth.ts
Normal file
19
src/services/api/auth.ts
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
import { ApiResponse, AuthUser, LoginCredentials } from '../../types'
|
||||||
|
import { apiClient } from './client'
|
||||||
|
|
||||||
|
export const authApi = {
|
||||||
|
login: async (credentials: LoginCredentials): Promise<ApiResponse<AuthUser>> => {
|
||||||
|
const response = await apiClient.post('/auth/login', credentials)
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
logout: async (): Promise<ApiResponse> => {
|
||||||
|
const response = await apiClient.post('/auth/logout')
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
me: async (): Promise<ApiResponse<AuthUser>> => {
|
||||||
|
const response = await apiClient.get('/auth/me')
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
}
|
45
src/services/api/certificates.ts
Normal file
45
src/services/api/certificates.ts
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
import {
|
||||||
|
ApiResponse,
|
||||||
|
Certificate,
|
||||||
|
CreateCertificateData,
|
||||||
|
DownloadCertificateParams,
|
||||||
|
PaginatedResponse
|
||||||
|
} from '../../types'
|
||||||
|
import { apiClient } from './client'
|
||||||
|
|
||||||
|
export const certificatesApi = {
|
||||||
|
getAll: async (page = 1, perPage = 50): Promise<ApiResponse<PaginatedResponse<Certificate>>> => {
|
||||||
|
const response = await apiClient.get('/certificates', {
|
||||||
|
params: { page, per_page: perPage }
|
||||||
|
})
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
create: async (data: CreateCertificateData): Promise<ApiResponse<Certificate>> => {
|
||||||
|
const response = await apiClient.post('/certificates', data)
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
revoke: async (certificateId: number): Promise<ApiResponse> => {
|
||||||
|
const response = await apiClient.post(`/certificates/${certificateId}/revoke`)
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
download: async (params: DownloadCertificateParams): Promise<Blob> => {
|
||||||
|
const response = await apiClient.get('/certificates/download', {
|
||||||
|
params,
|
||||||
|
responseType: 'blob',
|
||||||
|
})
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
getStats: async (): Promise<ApiResponse<{
|
||||||
|
total: number
|
||||||
|
active: number
|
||||||
|
revoked: number
|
||||||
|
expiring_soon: Certificate[]
|
||||||
|
}>> => {
|
||||||
|
const response = await apiClient.get('/certificates/stats')
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
}
|
57
src/services/api/client.ts
Normal file
57
src/services/api/client.ts
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
import axios from 'axios'
|
||||||
|
import toast from 'react-hot-toast'
|
||||||
|
|
||||||
|
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || '/api'
|
||||||
|
|
||||||
|
export const apiClient = axios.create({
|
||||||
|
baseURL: API_BASE_URL,
|
||||||
|
timeout: 30000,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Request interceptor
|
||||||
|
apiClient.interceptors.request.use(
|
||||||
|
(config) => {
|
||||||
|
// Add auth token if available
|
||||||
|
const authData = localStorage.getItem('auth-storage')
|
||||||
|
if (authData) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(authData)
|
||||||
|
if (parsed.state?.user?.token) {
|
||||||
|
config.headers.Authorization = `Bearer ${parsed.state.user.token}`
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error parsing auth data:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return config
|
||||||
|
},
|
||||||
|
(error) => {
|
||||||
|
return Promise.reject(error)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// Response interceptor
|
||||||
|
apiClient.interceptors.response.use(
|
||||||
|
(response) => {
|
||||||
|
return response
|
||||||
|
},
|
||||||
|
(error) => {
|
||||||
|
if (error.response?.status === 401) {
|
||||||
|
// Clear auth state and redirect to login
|
||||||
|
localStorage.removeItem('auth-storage')
|
||||||
|
window.location.href = '/login'
|
||||||
|
toast.error('Session expired. Please login again.')
|
||||||
|
} else if (error.response?.status === 403) {
|
||||||
|
toast.error('You do not have permission to perform this action.')
|
||||||
|
} else if (error.response?.status >= 500) {
|
||||||
|
toast.error('Server error. Please try again later.')
|
||||||
|
} else if (error.code === 'ECONNABORTED') {
|
||||||
|
toast.error('Request timeout. Please check your connection.')
|
||||||
|
}
|
||||||
|
|
||||||
|
return Promise.reject(error)
|
||||||
|
}
|
||||||
|
)
|
6
src/services/api/index.ts
Normal file
6
src/services/api/index.ts
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
export { authApi } from './auth'
|
||||||
|
export { certificatesApi } from './certificates'
|
||||||
|
export { perimetersApi } from './perimeters'
|
||||||
|
export { usersApi } from './users'
|
||||||
|
export { dashboardApi } from './dashboard'
|
||||||
|
export { apiClient } from './client'
|
182
src/styles/globals.css
Normal file
182
src/styles/globals.css
Normal file
@ -0,0 +1,182 @@
|
|||||||
|
@import 'tailwindcss/base';
|
||||||
|
@import 'tailwindcss/components';
|
||||||
|
@import 'tailwindcss/utilities';
|
||||||
|
|
||||||
|
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap');
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
:root {
|
||||||
|
--background: 0 0% 100%;
|
||||||
|
--foreground: 222.2 84% 4.9%;
|
||||||
|
--card: 0 0% 100%;
|
||||||
|
--card-foreground: 222.2 84% 4.9%;
|
||||||
|
--popover: 0 0% 100%;
|
||||||
|
--popover-foreground: 222.2 84% 4.9%;
|
||||||
|
--primary: 221.2 83.2% 53.3%;
|
||||||
|
--primary-foreground: 210 40% 98%;
|
||||||
|
--secondary: 210 40% 96%;
|
||||||
|
--secondary-foreground: 222.2 84% 4.9%;
|
||||||
|
--muted: 210 40% 96%;
|
||||||
|
--muted-foreground: 215.4 16.3% 46.9%;
|
||||||
|
--accent: 210 40% 96%;
|
||||||
|
--accent-foreground: 222.2 84% 4.9%;
|
||||||
|
--destructive: 0 84.2% 60.2%;
|
||||||
|
--destructive-foreground: 210 40% 98%;
|
||||||
|
--border: 214.3 31.8% 91.4%;
|
||||||
|
--input: 214.3 31.8% 91.4%;
|
||||||
|
--ring: 221.2 83.2% 53.3%;
|
||||||
|
--radius: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark {
|
||||||
|
--background: 222.2 84% 4.9%;
|
||||||
|
--foreground: 210 40% 98%;
|
||||||
|
--card: 222.2 84% 4.9%;
|
||||||
|
--card-foreground: 210 40% 98%;
|
||||||
|
--popover: 222.2 84% 4.9%;
|
||||||
|
--popover-foreground: 210 40% 98%;
|
||||||
|
--primary: 217.2 91.2% 59.8%;
|
||||||
|
--primary-foreground: 222.2 84% 4.9%;
|
||||||
|
--secondary: 217.2 32.6% 17.5%;
|
||||||
|
--secondary-foreground: 210 40% 98%;
|
||||||
|
--muted: 217.2 32.6% 17.5%;
|
||||||
|
--muted-foreground: 215 20.2% 65.1%;
|
||||||
|
--accent: 217.2 32.6% 17.5%;
|
||||||
|
--accent-foreground: 210 40% 98%;
|
||||||
|
--destructive: 0 62.8% 30.6%;
|
||||||
|
--destructive-foreground: 210 40% 98%;
|
||||||
|
--border: 217.2 32.6% 17.5%;
|
||||||
|
--input: 217.2 32.6% 17.5%;
|
||||||
|
--ring: 224.3 76.3% 94.1%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
* {
|
||||||
|
@apply border-border;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
@apply bg-background text-foreground font-sans;
|
||||||
|
font-feature-settings: "rlig" 1, "calt" 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1, h2, h3, h4, h5, h6 {
|
||||||
|
@apply font-semibold tracking-tight;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
@apply text-3xl lg:text-4xl;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
@apply text-2xl lg:text-3xl;
|
||||||
|
}
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
@apply text-xl lg:text-2xl;
|
||||||
|
}
|
||||||
|
|
||||||
|
h4 {
|
||||||
|
@apply text-lg lg:text-xl;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer components {
|
||||||
|
.btn {
|
||||||
|
@apply inline-flex items-center justify-center rounded-md text-sm font-medium
|
||||||
|
transition-colors focus-visible:outline-none focus-visible:ring-2
|
||||||
|
focus-visible:ring-ring focus-visible:ring-offset-2 disabled:opacity-50
|
||||||
|
disabled:pointer-events-none ring-offset-background;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
@apply btn bg-primary text-primary-foreground hover:bg-primary/90 h-10 py-2 px-4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
@apply btn bg-secondary text-secondary-foreground hover:bg-secondary/80 h-10 py-2 px-4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-destructive {
|
||||||
|
@apply btn bg-destructive text-destructive-foreground hover:bg-destructive/90 h-10 py-2 px-4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-outline {
|
||||||
|
@apply btn border border-input bg-background hover:bg-accent hover:text-accent-foreground h-10 py-2 px-4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-ghost {
|
||||||
|
@apply btn hover:bg-accent hover:text-accent-foreground h-10 py-2 px-4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-sm {
|
||||||
|
@apply h-9 px-3 text-xs;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-lg {
|
||||||
|
@apply h-11 px-8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input {
|
||||||
|
@apply flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm
|
||||||
|
ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium
|
||||||
|
placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2
|
||||||
|
focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed
|
||||||
|
disabled:opacity-50;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
@apply rounded-lg border bg-card text-card-foreground shadow-sm;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-header {
|
||||||
|
@apply flex flex-col space-y-1.5 p-6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-content {
|
||||||
|
@apply p-6 pt-0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-footer {
|
||||||
|
@apply flex items-center p-6 pt-0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Custom scrollbar */
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
@apply bg-secondary;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
@apply bg-muted-foreground/50 rounded-full;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
@apply bg-muted-foreground/70;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Animation utilities */
|
||||||
|
.animate-in {
|
||||||
|
animation: animateIn 0.3s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes animateIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(10px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Focus ring for accessibility */
|
||||||
|
.focus-ring {
|
||||||
|
@apply focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:ring-offset-background;
|
||||||
|
}
|
90
src/types/index.ts
Normal file
90
src/types/index.ts
Normal file
@ -0,0 +1,90 @@
|
|||||||
|
export interface User {
|
||||||
|
id: number
|
||||||
|
username: string
|
||||||
|
role: 'admin' | 'user'
|
||||||
|
created_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuthUser extends User {
|
||||||
|
token?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LoginCredentials {
|
||||||
|
username: string
|
||||||
|
password: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Certificate {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
type: 'root' | 'intermediate' | 'simple'
|
||||||
|
functional_perimeter_id?: number
|
||||||
|
perimeter_name?: string
|
||||||
|
expiration_date: string
|
||||||
|
is_revoked: boolean
|
||||||
|
revoked_at?: string
|
||||||
|
created_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateCertificateData {
|
||||||
|
subdomain_name: string
|
||||||
|
functional_perimeter_id: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FunctionalPerimeter {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
intermediate_cert_name: string
|
||||||
|
created_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreatePerimeterData {
|
||||||
|
name: string
|
||||||
|
intermediate_passphrase?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateUserData {
|
||||||
|
username: string
|
||||||
|
password: string
|
||||||
|
role: 'admin' | 'user'
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdatePasswordData {
|
||||||
|
user_id: number
|
||||||
|
new_password: string
|
||||||
|
confirm_password: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DashboardStats {
|
||||||
|
total_certificates: number
|
||||||
|
active_certificates: number
|
||||||
|
revoked_certificates: number
|
||||||
|
total_perimeters: number
|
||||||
|
total_users: number
|
||||||
|
expiring_soon: Certificate[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApiResponse<T = unknown> {
|
||||||
|
success: boolean
|
||||||
|
data?: T
|
||||||
|
message?: string
|
||||||
|
errors?: Record<string, string[]>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PaginatedResponse<T> {
|
||||||
|
data: T[]
|
||||||
|
current_page: number
|
||||||
|
per_page: number
|
||||||
|
total: number
|
||||||
|
last_page: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Theme = 'light' | 'dark'
|
||||||
|
|
||||||
|
export type Language = 'en' | 'fr' | 'de' | 'es' | 'it' | 'pt' | 'ja' | 'ru' | 'ar' | 'hi' | 'zh'
|
||||||
|
|
||||||
|
export interface DownloadCertificateParams {
|
||||||
|
type: 'root' | 'intermediate' | 'simple'
|
||||||
|
file: string
|
||||||
|
perimeter?: string
|
||||||
|
}
|
72
tailwind.config.js
Normal file
72
tailwind.config.js
Normal file
@ -0,0 +1,72 @@
|
|||||||
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
export default {
|
||||||
|
content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
|
||||||
|
darkMode: 'class',
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
colors: {
|
||||||
|
border: 'hsl(var(--border))',
|
||||||
|
input: 'hsl(var(--input))',
|
||||||
|
ring: 'hsl(var(--ring))',
|
||||||
|
background: 'hsl(var(--background))',
|
||||||
|
foreground: 'hsl(var(--foreground))',
|
||||||
|
primary: {
|
||||||
|
DEFAULT: 'hsl(var(--primary))',
|
||||||
|
foreground: 'hsl(var(--primary-foreground))',
|
||||||
|
},
|
||||||
|
secondary: {
|
||||||
|
DEFAULT: 'hsl(var(--secondary))',
|
||||||
|
foreground: 'hsl(var(--secondary-foreground))',
|
||||||
|
},
|
||||||
|
destructive: {
|
||||||
|
DEFAULT: 'hsl(var(--destructive))',
|
||||||
|
foreground: 'hsl(var(--destructive-foreground))',
|
||||||
|
},
|
||||||
|
muted: {
|
||||||
|
DEFAULT: 'hsl(var(--muted))',
|
||||||
|
foreground: 'hsl(var(--muted-foreground))',
|
||||||
|
},
|
||||||
|
accent: {
|
||||||
|
DEFAULT: 'hsl(var(--accent))',
|
||||||
|
foreground: 'hsl(var(--accent-foreground))',
|
||||||
|
},
|
||||||
|
popover: {
|
||||||
|
DEFAULT: 'hsl(var(--popover))',
|
||||||
|
foreground: 'hsl(var(--popover-foreground))',
|
||||||
|
},
|
||||||
|
card: {
|
||||||
|
DEFAULT: 'hsl(var(--card))',
|
||||||
|
foreground: 'hsl(var(--card-foreground))',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
borderRadius: {
|
||||||
|
lg: 'var(--radius)',
|
||||||
|
md: 'calc(var(--radius) - 2px)',
|
||||||
|
sm: 'calc(var(--radius) - 4px)',
|
||||||
|
},
|
||||||
|
fontFamily: {
|
||||||
|
sans: ['Inter', 'system-ui', 'sans-serif'],
|
||||||
|
},
|
||||||
|
animation: {
|
||||||
|
'fade-in': 'fadeIn 0.3s ease-in-out',
|
||||||
|
'slide-up': 'slideUp 0.3s ease-out',
|
||||||
|
'slide-down': 'slideDown 0.3s ease-out',
|
||||||
|
},
|
||||||
|
keyframes: {
|
||||||
|
fadeIn: {
|
||||||
|
'0%': { opacity: '0' },
|
||||||
|
'100%': { opacity: '1' },
|
||||||
|
},
|
||||||
|
slideUp: {
|
||||||
|
'0%': { transform: 'translateY(10px)', opacity: '0' },
|
||||||
|
'100%': { transform: 'translateY(0)', opacity: '1' },
|
||||||
|
},
|
||||||
|
slideDown: {
|
||||||
|
'0%': { transform: 'translateY(-10px)', opacity: '0' },
|
||||||
|
'100%': { transform: 'translateY(0)', opacity: '1' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: [],
|
||||||
|
}
|
40
vite.config.ts
Normal file
40
vite.config.ts
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
import path from 'path'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': path.resolve(__dirname, './src'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
port: 3000,
|
||||||
|
proxy: {
|
||||||
|
'/api': {
|
||||||
|
target: 'http://localhost:980',
|
||||||
|
changeOrigin: true,
|
||||||
|
rewrite: (path) => path.replace(/^\/api/, '/api/v1')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
build: {
|
||||||
|
outDir: 'dist',
|
||||||
|
sourcemap: true,
|
||||||
|
rollupOptions: {
|
||||||
|
output: {
|
||||||
|
manualChunks: {
|
||||||
|
vendor: ['react', 'react-dom'],
|
||||||
|
router: ['react-router-dom'],
|
||||||
|
ui: ['lucide-react', 'react-hot-toast']
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
test: {
|
||||||
|
globals: true,
|
||||||
|
environment: 'jsdom',
|
||||||
|
setupFiles: ['./src/test/setup.ts'],
|
||||||
|
}
|
||||||
|
})
|
Reference in New Issue
Block a user