mirror of
https://github.com/tips-of-mine/gestion-certificats2.git
synced 2025-06-27 21:48:43 +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