Modernisation du projet Gestion Certificat

This commit is contained in:
tips-of-mine
2025-06-16 14:36:10 +02:00
committed by GitHub
parent 145476960b
commit f32805f1c1
32 changed files with 9996 additions and 0 deletions

24
.env.example Normal file
View 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
View 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
View 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
View 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();

View 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);
}
}

View 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');
}
}
}

View 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'];
}
}
}

View 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
View 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
View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

56
package.json Normal file
View 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
View 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
View 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
View 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

View 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'

View 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
View 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
View 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
View 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
View 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
View 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
},
}

View 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
},
}

View 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)
}
)

View 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
View 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
View 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
View 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
View 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'],
}
})