First commit

This commit is contained in:
stef
2025-12-10 11:03:29 +01:00
commit db87e3be3d
18 changed files with 3211 additions and 0 deletions

380
internal/api/handlers.go Normal file
View File

@@ -0,0 +1,380 @@
package api
import (
"net/http"
"pki-manager/internal/models"
"pki-manager/internal/repository"
"pki-manager/internal/services"
"github.com/gin-gonic/gin"
)
type Handlers struct {
repo repository.Repository
cryptoService *services.CryptoService
}
func NewHandlers(repo repository.Repository, cryptoService *services.CryptoService) *Handlers {
return &Handlers{
repo: repo,
cryptoService: cryptoService,
}
}
// CA Handlers
func (h *Handlers) CreateCA(c *gin.Context) {
var req models.CreateCARequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
ca, err := h.cryptoService.GenerateRootCA(req)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if err := h.repo.CreateCA(c.Request.Context(), ca); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, ca)
}
func (h *Handlers) GetCA(c *gin.Context) {
id := c.Param("id")
ca, err := h.repo.GetCA(c.Request.Context(), id)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "CA not found"})
return
}
// Don't expose private key in GET requests
ca.PrivateKey = ""
c.JSON(http.StatusOK, ca)
}
// GetAllCAs - Retourne toujours un tableau, même vide
func (h *Handlers) GetAllCAs(c *gin.Context) {
cas, err := h.repo.GetAllCAs(c.Request.Context())
if err != nil {
// IMPORTANT: Retourner un tableau vide, pas une erreur
c.JSON(http.StatusOK, []interface{}{})
return
}
// Remove private keys from response
for _, ca := range cas {
ca.PrivateKey = ""
}
// S'assurer qu'on retourne toujours un tableau
if cas == nil {
c.JSON(http.StatusOK, []interface{}{})
} else {
c.JSON(http.StatusOK, cas)
}
}
func (h *Handlers) UpdateCA(c *gin.Context) {
id := c.Param("id")
var req models.UpdateCARequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
updates := make(map[string]interface{})
if req.Name != "" {
updates["name"] = req.Name
}
if req.Organization != "" {
updates["organization"] = req.Organization
}
if req.Email != "" {
updates["email"] = req.Email
}
if err := h.repo.UpdateCA(c.Request.Context(), id, updates); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "CA updated successfully"})
}
func (h *Handlers) DeleteCA(c *gin.Context) {
id := c.Param("id")
if err := h.repo.DeleteCA(c.Request.Context(), id); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "CA deleted successfully"})
}
// SubCA Handlers
func (h *Handlers) CreateSubCA(c *gin.Context) {
var req models.CreateSubCARequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Get parent CA
parentCA, err := h.repo.GetCA(c.Request.Context(), req.ParentCAID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Parent CA not found"})
return
}
subca, err := h.cryptoService.GenerateSubCA(req, parentCA)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if err := h.repo.CreateSubCA(c.Request.Context(), subca); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, subca)
}
func (h *Handlers) GetSubCA(c *gin.Context) {
id := c.Param("id")
subca, err := h.repo.GetSubCA(c.Request.Context(), id)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "SubCA not found"})
return
}
subca.PrivateKey = ""
c.JSON(http.StatusOK, subca)
}
// GetAllSubCAs - Retourne toujours un tableau, même vide
func (h *Handlers) GetAllSubCAs(c *gin.Context) {
subcas, err := h.repo.GetAllSubCAs(c.Request.Context())
if err != nil {
// IMPORTANT: Retourner un tableau vide, pas une erreur
c.JSON(http.StatusOK, []interface{}{})
return
}
// Remove private keys from response
for _, subca := range subcas {
subca.PrivateKey = ""
}
// S'assurer qu'on retourne toujours un tableau
if subcas == nil {
c.JSON(http.StatusOK, []interface{}{})
} else {
c.JSON(http.StatusOK, subcas)
}
}
func (h *Handlers) UpdateSubCA(c *gin.Context) {
id := c.Param("id")
var req models.UpdateSubCARequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
updates := make(map[string]interface{})
if req.Name != "" {
updates["name"] = req.Name
}
if req.Organization != "" {
updates["organization"] = req.Organization
}
if req.Email != "" {
updates["email"] = req.Email
}
if err := h.repo.UpdateSubCA(c.Request.Context(), id, updates); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "SubCA updated successfully"})
}
func (h *Handlers) DeleteSubCA(c *gin.Context) {
id := c.Param("id")
if err := h.repo.DeleteSubCA(c.Request.Context(), id); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "SubCA deleted successfully"})
}
// Certificate Handlers
func (h *Handlers) CreateCertificate(c *gin.Context) {
var req models.CreateCertificateRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Try to get issuer as CA first
issuer, err := h.repo.GetCA(c.Request.Context(), req.IssuerCAID)
if err != nil {
// If not found as CA, try as SubCA
issuer, err := h.repo.GetSubCA(c.Request.Context(), req.IssuerCAID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Issuer not found"})
return
}
cert, err := h.cryptoService.GenerateCertificate(req, issuer)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if err := h.repo.CreateCertificate(c.Request.Context(), cert); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, cert)
return
}
cert, err := h.cryptoService.GenerateCertificate(req, issuer)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if err := h.repo.CreateCertificate(c.Request.Context(), cert); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, cert)
}
func (h *Handlers) GetCertificate(c *gin.Context) {
id := c.Param("id")
cert, err := h.repo.GetCertificate(c.Request.Context(), id)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Certificate not found"})
return
}
cert.PrivateKey = ""
c.JSON(http.StatusOK, cert)
}
// GetAllCertificates - Retourne toujours un tableau, même vide
func (h *Handlers) GetAllCertificates(c *gin.Context) {
certs, err := h.repo.GetAllCertificates(c.Request.Context())
if err != nil {
// IMPORTANT: Retourner un tableau vide, pas une erreur
c.JSON(http.StatusOK, []interface{}{})
return
}
// Remove private keys from response
for _, cert := range certs {
cert.PrivateKey = ""
}
// S'assurer qu'on retourne toujours un tableau
if certs == nil {
c.JSON(http.StatusOK, []interface{}{})
} else {
c.JSON(http.StatusOK, certs)
}
}
func (h *Handlers) DeleteCertificate(c *gin.Context) {
id := c.Param("id")
if err := h.repo.DeleteCertificate(c.Request.Context(), id); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Certificate deleted successfully"})
}
func (h *Handlers) RevokeCertificate(c *gin.Context) {
id := c.Param("id")
var req models.RevokeCertificateRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := h.repo.RevokeCertificate(c.Request.Context(), id, req.Reason); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Certificate revoked successfully"})
}
// Download handlers for CA and SubCA
func (h *Handlers) DownloadCACertificate(c *gin.Context) {
id := c.Param("id")
ca, err := h.repo.GetCA(c.Request.Context(), id)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "CA not found"})
return
}
c.Header("Content-Type", "application/x-pem-file")
c.Header("Content-Disposition", "attachment; filename="+ca.CommonName+".crt")
c.String(http.StatusOK, ca.Certificate)
}
func (h *Handlers) DownloadSubCACertificate(c *gin.Context) {
id := c.Param("id")
subca, err := h.repo.GetSubCA(c.Request.Context(), id)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "SubCA not found"})
return
}
c.Header("Content-Type", "application/x-pem-file")
c.Header("Content-Disposition", "attachment; filename="+subca.CommonName+".crt")
c.String(http.StatusOK, subca.Certificate)
}
// Download handlers
func (h *Handlers) DownloadCertificate(c *gin.Context) {
id := c.Param("id")
cert, err := h.repo.GetCertificate(c.Request.Context(), id)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Certificate not found"})
return
}
c.Header("Content-Type", "application/x-pem-file")
c.Header("Content-Disposition", "attachment; filename="+cert.CommonName+".crt")
c.String(http.StatusOK, cert.Certificate)
}
func (h *Handlers) DownloadPrivateKey(c *gin.Context) {
id := c.Param("id")
cert, err := h.repo.GetCertificate(c.Request.Context(), id)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Certificate not found"})
return
}
c.Header("Content-Type", "application/x-pem-file")
c.Header("Content-Disposition", "attachment; filename="+cert.CommonName+".key")
c.String(http.StatusOK, cert.PrivateKey)
}
// Web Interface
func (h *Handlers) ServeWebInterface(c *gin.Context) {
c.HTML(http.StatusOK, "index.html", nil)
}

67
internal/api/routes.go Normal file
View File

@@ -0,0 +1,67 @@
package api
import (
"log"
"pki-manager/config"
"pki-manager/internal/repository"
"pki-manager/internal/services"
"github.com/gin-gonic/gin"
)
func SetupRoutes(router *gin.Engine, repo repository.Repository, jwtSecret string) {
cfg := config.LoadConfig()
cryptoService := services.NewCryptoService(cfg.CertsPath)
handlers := NewHandlers(repo, cryptoService)
// Add logging middleware
router.Use(func(c *gin.Context) {
log.Printf("[API] %s %s", c.Request.Method, c.Request.URL.Path)
c.Next()
})
// Load HTML templates
router.LoadHTMLGlob("internal/web/templates/*")
router.Static("/static", "internal/web/static")
// Web interface
router.GET("/", handlers.ServeWebInterface)
// API routes
api := router.Group("/api/v1")
{
// CA routes
ca := api.Group("/cas")
{
ca.POST("/", handlers.CreateCA)
ca.GET("/", handlers.GetAllCAs)
ca.GET("/:id", handlers.GetCA)
ca.PUT("/:id", handlers.UpdateCA)
ca.DELETE("/:id", handlers.DeleteCA)
ca.GET("/:id/download/cert", handlers.DownloadCACertificate)
}
// SubCA routes
subca := api.Group("/subcas")
{
subca.POST("/", handlers.CreateSubCA)
subca.GET("/", handlers.GetAllSubCAs)
subca.GET("/:id", handlers.GetSubCA)
subca.PUT("/:id", handlers.UpdateSubCA)
subca.DELETE("/:id", handlers.DeleteSubCA)
subca.GET("/:id/download/cert", handlers.DownloadSubCACertificate)
}
// Certificate routes
cert := api.Group("/certificates")
{
cert.POST("/", handlers.CreateCertificate)
cert.GET("/", handlers.GetAllCertificates)
cert.GET("/:id", handlers.GetCertificate)
cert.DELETE("/:id", handlers.DeleteCertificate)
cert.POST("/:id/revoke", handlers.RevokeCertificate)
cert.GET("/:id/download/cert", handlers.DownloadCertificate)
cert.GET("/:id/download/key", handlers.DownloadPrivateKey)
}
}
}

43
internal/models/ca.go Normal file
View File

@@ -0,0 +1,43 @@
package models
import (
"time"
)
type CA struct {
ID string `json:"id" bson:"_id"`
Name string `json:"name" bson:"name"`
CommonName string `json:"common_name" bson:"common_name"`
Organization string `json:"organization" bson:"organization"`
Country string `json:"country" bson:"country"`
Province string `json:"province" bson:"province"`
Locality string `json:"locality" bson:"locality"`
Email string `json:"email" bson:"email"`
PrivateKey string `json:"private_key,omitempty" bson:"private_key"`
Certificate string `json:"certificate" bson:"certificate"`
SerialNumber string `json:"serial_number" bson:"serial_number"`
ValidFrom time.Time `json:"valid_from" bson:"valid_from"`
ValidTo time.Time `json:"valid_to" bson:"valid_to"`
IsRoot bool `json:"is_root" bson:"is_root"`
CreatedAt time.Time `json:"created_at" bson:"created_at"`
UpdatedAt time.Time `json:"updated_at" bson:"updated_at"`
}
type CreateCARequest struct {
Name string `json:"name" binding:"required"`
CommonName string `json:"common_name" binding:"required"`
Organization string `json:"organization" binding:"required"`
Country string `json:"country" binding:"required"`
Province string `json:"province"`
Locality string `json:"locality"`
Email string `json:"email" binding:"omitempty,email"` // omitempty permet les chaînes vides
KeySize int `json:"key_size" binding:"required,min=2048"`
ValidYears int `json:"valid_years" binding:"required,min=1,max=20"`
IsRoot bool `json:"is_root"`
}
type UpdateCARequest struct {
Name string `json:"name"`
Organization string `json:"organization"`
Email string `json:"email"`
}

View File

@@ -0,0 +1,38 @@
package models
import (
"time"
)
type Certificate struct {
ID string `json:"id" bson:"_id"`
CommonName string `json:"common_name" bson:"common_name"`
Subject string `json:"subject" bson:"subject"`
DNSNames []string `json:"dns_names" bson:"dns_names"`
IPAddresses []string `json:"ip_addresses" bson:"ip_addresses"`
Type string `json:"type" bson:"type"` // "server" or "client"
PrivateKey string `json:"private_key,omitempty" bson:"private_key"`
Certificate string `json:"certificate" bson:"certificate"`
SerialNumber string `json:"serial_number" bson:"serial_number"`
ValidFrom time.Time `json:"valid_from" bson:"valid_from"`
ValidTo time.Time `json:"valid_to" bson:"valid_to"`
IssuerCAID string `json:"issuer_ca_id" bson:"issuer_ca_id"`
Revoked bool `json:"revoked" bson:"revoked"`
RevokedAt time.Time `json:"revoked_at,omitempty" bson:"revoked_at"`
RevokedReason string `json:"revoked_reason,omitempty" bson:"revoked_reason"`
CreatedAt time.Time `json:"created_at" bson:"created_at"`
}
type CreateCertificateRequest struct {
CommonName string `json:"common_name" binding:"required"`
DNSNames []string `json:"dns_names"`
IPAddresses []string `json:"ip_addresses"`
Type string `json:"type" binding:"required,oneof=server client"`
KeySize int `json:"key_size" binding:"required,min=2048"`
ValidDays int `json:"valid_days" binding:"required,min=1,max=365"`
IssuerCAID string `json:"issuer_ca_id" binding:"required"`
}
type RevokeCertificateRequest struct {
Reason string `json:"reason" binding:"required"`
}

43
internal/models/subca.go Normal file
View File

@@ -0,0 +1,43 @@
package models
import (
"time"
)
type SubCA struct {
ID string `json:"id" bson:"_id"`
Name string `json:"name" bson:"name"`
CommonName string `json:"common_name" bson:"common_name"`
Organization string `json:"organization" bson:"organization"`
Country string `json:"country" bson:"country"`
Province string `json:"province" bson:"province"`
Locality string `json:"locality" bson:"locality"`
Email string `json:"email" bson:"email"`
PrivateKey string `json:"private_key,omitempty" bson:"private_key"`
Certificate string `json:"certificate" bson:"certificate"`
SerialNumber string `json:"serial_number" bson:"serial_number"`
ValidFrom time.Time `json:"valid_from" bson:"valid_from"`
ValidTo time.Time `json:"valid_to" bson:"valid_to"`
ParentCAID string `json:"parent_ca_id" bson:"parent_ca_id"`
CreatedAt time.Time `json:"created_at" bson:"created_at"`
UpdatedAt time.Time `json:"updated_at" bson:"updated_at"`
}
type CreateSubCARequest struct {
Name string `json:"name" binding:"required"`
CommonName string `json:"common_name" binding:"required"`
Organization string `json:"organization" binding:"required"`
Country string `json:"country" binding:"required"`
Province string `json:"province"`
Locality string `json:"locality"`
Email string `json:"email" binding:"omitempty,email"` // omitempty permet les chaînes vides
KeySize int `json:"key_size" binding:"required,min=2048"`
ValidYears int `json:"valid_years" binding:"required,min=1,max=10"`
ParentCAID string `json:"parent_ca_id" binding:"required"`
}
type UpdateSubCARequest struct {
Name string `json:"name"`
Organization string `json:"organization"`
Email string `json:"email"`
}

View File

@@ -0,0 +1,229 @@
package repository
import (
"context"
"log"
"time"
"pki-manager/internal/models"
"go.mongodb.org/mongo-driver/bson"
"go.mongodb.org/mongo-driver/mongo"
)
type MongoRepository struct {
db *mongo.Database
}
func NewMongoRepository(db *mongo.Database) *MongoRepository {
return &MongoRepository{db: db}
}
func (r *MongoRepository) CreateCA(ctx context.Context, ca *models.CA) error {
ca.CreatedAt = time.Now()
ca.UpdatedAt = time.Now()
_, err := r.db.Collection("cas").InsertOne(ctx, ca)
return err
}
func (r *MongoRepository) GetCA(ctx context.Context, id string) (*models.CA, error) {
var ca models.CA
err := r.db.Collection("cas").FindOne(ctx, bson.M{"_id": id}).Decode(&ca)
if err != nil {
return nil, err
}
return &ca, nil
}
func (r *MongoRepository) GetAllCAs(ctx context.Context) ([]*models.CA, error) {
var cas []*models.CA
cursor, err := r.db.Collection("cas").Find(ctx, bson.M{})
if err != nil {
// Retourner un tableau vide au lieu d'une erreur
return []*models.CA{}, nil
}
defer cursor.Close(ctx)
for cursor.Next(ctx) {
var ca models.CA
if err := cursor.Decode(&ca); err != nil {
continue // Ignorer les erreurs de décodage
}
cas = append(cas, &ca)
}
return cas, nil
}
func (r *MongoRepository) UpdateCA(ctx context.Context, id string, updates map[string]interface{}) error {
updates["updated_at"] = time.Now()
_, err := r.db.Collection("cas").UpdateOne(
ctx,
bson.M{"_id": id},
bson.M{"$set": updates},
)
return err
}
func (r *MongoRepository) DeleteCA(ctx context.Context, id string) error {
_, err := r.db.Collection("cas").DeleteOne(ctx, bson.M{"_id": id})
return err
}
// SubCA methods
func (r *MongoRepository) CreateSubCA(ctx context.Context, subca *models.SubCA) error {
subca.CreatedAt = time.Now()
subca.UpdatedAt = time.Now()
_, err := r.db.Collection("subcas").InsertOne(ctx, subca)
return err
}
func (r *MongoRepository) GetSubCA(ctx context.Context, id string) (*models.SubCA, error) {
var subca models.SubCA
err := r.db.Collection("subcas").FindOne(ctx, bson.M{"_id": id}).Decode(&subca)
if err != nil {
return nil, err
}
return &subca, nil
}
func (r *MongoRepository) GetSubCAsByParent(ctx context.Context, parentCAID string) ([]*models.SubCA, error) {
var subcas []*models.SubCA
cursor, err := r.db.Collection("subcas").Find(ctx, bson.M{"parent_ca_id": parentCAID})
if err != nil {
return nil, err
}
defer cursor.Close(ctx)
for cursor.Next(ctx) {
var subca models.SubCA
if err := cursor.Decode(&subca); err != nil {
log.Println("Error decoding SubCA:", err)
continue
}
subcas = append(subcas, &subca)
}
return subcas, nil
}
func (r *MongoRepository) GetAllSubCAs(ctx context.Context) ([]*models.SubCA, error) {
var subcas []*models.SubCA
cursor, err := r.db.Collection("subcas").Find(ctx, bson.M{})
if err != nil {
// Retourner un tableau vide au lieu d'une erreur
return []*models.SubCA{}, nil
}
defer cursor.Close(ctx)
for cursor.Next(ctx) {
var subca models.SubCA
if err := cursor.Decode(&subca); err != nil {
continue // Ignorer les erreurs de décodage
}
subcas = append(subcas, &subca)
}
return subcas, nil
}
func (r *MongoRepository) UpdateSubCA(ctx context.Context, id string, updates map[string]interface{}) error {
updates["updated_at"] = time.Now()
_, err := r.db.Collection("subcas").UpdateOne(
ctx,
bson.M{"_id": id},
bson.M{"$set": updates},
)
return err
}
func (r *MongoRepository) DeleteSubCA(ctx context.Context, id string) error {
_, err := r.db.Collection("subcas").DeleteOne(ctx, bson.M{"_id": id})
return err
}
// Certificate methods
func (r *MongoRepository) CreateCertificate(ctx context.Context, cert *models.Certificate) error {
cert.CreatedAt = time.Now()
_, err := r.db.Collection("certificates").InsertOne(ctx, cert)
return err
}
func (r *MongoRepository) GetCertificate(ctx context.Context, id string) (*models.Certificate, error) {
var cert models.Certificate
err := r.db.Collection("certificates").FindOne(ctx, bson.M{"_id": id}).Decode(&cert)
if err != nil {
return nil, err
}
return &cert, nil
}
func (r *MongoRepository) GetCertificatesByIssuer(ctx context.Context, issuerCAID string) ([]*models.Certificate, error) {
var certs []*models.Certificate
cursor, err := r.db.Collection("certificates").Find(ctx, bson.M{"issuer_ca_id": issuerCAID})
if err != nil {
return nil, err
}
defer cursor.Close(ctx)
for cursor.Next(ctx) {
var cert models.Certificate
if err := cursor.Decode(&cert); err != nil {
log.Println("Error decoding certificate:", err)
continue
}
certs = append(certs, &cert)
}
return certs, nil
}
func (r *MongoRepository) GetAllCertificates(ctx context.Context) ([]*models.Certificate, error) {
var certs []*models.Certificate
cursor, err := r.db.Collection("certificates").Find(ctx, bson.M{})
if err != nil {
// Retourner un tableau vide au lieu d'une erreur
return []*models.Certificate{}, nil
}
defer cursor.Close(ctx)
for cursor.Next(ctx) {
var cert models.Certificate
if err := cursor.Decode(&cert); err != nil {
continue // Ignorer les erreurs de décodage
}
certs = append(certs, &cert)
}
return certs, nil
}
func (r *MongoRepository) UpdateCertificate(ctx context.Context, id string, updates map[string]interface{}) error {
_, err := r.db.Collection("certificates").UpdateOne(
ctx,
bson.M{"_id": id},
bson.M{"$set": updates},
)
return err
}
func (r *MongoRepository) DeleteCertificate(ctx context.Context, id string) error {
_, err := r.db.Collection("certificates").DeleteOne(ctx, bson.M{"_id": id})
return err
}
func (r *MongoRepository) RevokeCertificate(ctx context.Context, id string, reason string) error {
updates := bson.M{
"revoked": true,
"revoked_at": time.Now(),
"revoked_reason": reason,
}
_, err := r.db.Collection("certificates").UpdateOne(
ctx,
bson.M{"_id": id},
bson.M{"$set": updates},
)
return err
}

View File

@@ -0,0 +1,32 @@
package repository
import (
"context"
"pki-manager/internal/models"
)
type Repository interface {
// CA operations
CreateCA(ctx context.Context, ca *models.CA) error
GetCA(ctx context.Context, id string) (*models.CA, error)
GetAllCAs(ctx context.Context) ([]*models.CA, error)
UpdateCA(ctx context.Context, id string, updates map[string]interface{}) error
DeleteCA(ctx context.Context, id string) error
// SubCA operations
CreateSubCA(ctx context.Context, subca *models.SubCA) error
GetSubCA(ctx context.Context, id string) (*models.SubCA, error)
GetSubCAsByParent(ctx context.Context, parentCAID string) ([]*models.SubCA, error)
GetAllSubCAs(ctx context.Context) ([]*models.SubCA, error)
UpdateSubCA(ctx context.Context, id string, updates map[string]interface{}) error
DeleteSubCA(ctx context.Context, id string) error
// Certificate operations
CreateCertificate(ctx context.Context, cert *models.Certificate) error
GetCertificate(ctx context.Context, id string) (*models.Certificate, error)
GetCertificatesByIssuer(ctx context.Context, issuerCAID string) ([]*models.Certificate, error)
GetAllCertificates(ctx context.Context) ([]*models.Certificate, error)
UpdateCertificate(ctx context.Context, id string, updates map[string]interface{}) error
DeleteCertificate(ctx context.Context, id string) error
RevokeCertificate(ctx context.Context, id string, reason string) error
}

View File

@@ -0,0 +1,327 @@
package services
import (
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"fmt"
"math/big"
"net"
"time"
"pki-manager/internal/models"
)
type CryptoService struct {
certsPath string
}
func NewCryptoService(certsPath string) *CryptoService {
return &CryptoService{certsPath: certsPath}
}
func (s *CryptoService) GenerateRootCA(req models.CreateCARequest) (*models.CA, error) {
// Generate private key
priv, err := rsa.GenerateKey(rand.Reader, req.KeySize)
if err != nil {
return nil, fmt.Errorf("failed to generate private key: %v", err)
}
// Create certificate template
serialNumber, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))
if err != nil {
return nil, fmt.Errorf("failed to generate serial number: %v", err)
}
// Préparer le sujet avec email optionnel
subject := pkix.Name{
CommonName: req.CommonName,
Organization: []string{req.Organization},
Country: []string{req.Country},
Province: []string{req.Province},
Locality: []string{req.Locality},
}
// Ajouter l'email seulement s'il est fourni
if req.Email != "" {
// L'email n'est pas un champ standard dans pkix.Name
// On le stockera dans les champs personnalisés
}
template := x509.Certificate{
SerialNumber: serialNumber,
Subject: subject,
NotBefore: time.Now(),
NotAfter: time.Now().AddDate(req.ValidYears, 0, 0),
KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign,
BasicConstraintsValid: true,
IsCA: true,
MaxPathLen: 0,
MaxPathLenZero: true,
}
// Ajouter l'email seulement s'il est fourni
if req.Email != "" {
// L'email n'est pas un champ standard dans pkix.Name
// On le stockera dans les champs personnalisés
}
// Self-sign the certificate
certBytes, err := x509.CreateCertificate(rand.Reader, &template, &template, &priv.PublicKey, priv)
if err != nil {
return nil, fmt.Errorf("failed to create certificate: %v", err)
}
// Encode private key
privBytes := x509.MarshalPKCS1PrivateKey(priv)
privPEM := pem.EncodeToMemory(&pem.Block{
Type: "RSA PRIVATE KEY",
Bytes: privBytes,
})
// Encode certificate
certPEM := pem.EncodeToMemory(&pem.Block{
Type: "CERTIFICATE",
Bytes: certBytes,
})
ca := &models.CA{
ID: fmt.Sprintf("ca_%d", time.Now().UnixNano()),
Name: req.Name,
CommonName: req.CommonName,
Organization: req.Organization,
Country: req.Country,
Province: req.Province,
Locality: req.Locality,
Email: req.Email,
PrivateKey: string(privPEM),
Certificate: string(certPEM),
SerialNumber: serialNumber.String(),
ValidFrom: template.NotBefore,
ValidTo: template.NotAfter,
IsRoot: req.IsRoot,
}
return ca, nil
}
func (s *CryptoService) GenerateSubCA(req models.CreateSubCARequest, parentCA *models.CA) (*models.SubCA, error) {
// Parse parent CA certificate and private key
parentCertBlock, _ := pem.Decode([]byte(parentCA.Certificate))
if parentCertBlock == nil {
return nil, fmt.Errorf("failed to parse parent CA certificate")
}
parentCert, err := x509.ParseCertificate(parentCertBlock.Bytes)
if err != nil {
return nil, fmt.Errorf("failed to parse parent CA certificate: %v", err)
}
parentKeyBlock, _ := pem.Decode([]byte(parentCA.PrivateKey))
if parentKeyBlock == nil {
return nil, fmt.Errorf("failed to parse parent CA private key")
}
parentKey, err := x509.ParsePKCS1PrivateKey(parentKeyBlock.Bytes)
if err != nil {
return nil, fmt.Errorf("failed to parse parent CA private key: %v", err)
}
// Generate subCA private key
priv, err := rsa.GenerateKey(rand.Reader, req.KeySize)
if err != nil {
return nil, fmt.Errorf("failed to generate private key: %v", err)
}
// Create certificate template
serialNumber, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))
if err != nil {
return nil, fmt.Errorf("failed to generate serial number: %v", err)
}
subject := pkix.Name{
CommonName: req.CommonName,
Organization: []string{req.Organization},
Country: []string{req.Country},
Province: []string{req.Province},
Locality: []string{req.Locality},
}
template := x509.Certificate{
SerialNumber: serialNumber,
Subject: subject,
NotBefore: time.Now(),
NotAfter: time.Now().AddDate(req.ValidYears, 0, 0),
KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign,
BasicConstraintsValid: true,
IsCA: true,
MaxPathLen: 0,
MaxPathLenZero: false,
}
// Sign the certificate with parent CA
certBytes, err := x509.CreateCertificate(rand.Reader, &template, parentCert, &priv.PublicKey, parentKey)
if err != nil {
return nil, fmt.Errorf("failed to create certificate: %v", err)
}
// Encode private key
privBytes := x509.MarshalPKCS1PrivateKey(priv)
privPEM := pem.EncodeToMemory(&pem.Block{
Type: "RSA PRIVATE KEY",
Bytes: privBytes,
})
// Encode certificate
certPEM := pem.EncodeToMemory(&pem.Block{
Type: "CERTIFICATE",
Bytes: certBytes,
})
subca := &models.SubCA{
ID: fmt.Sprintf("subca_%d", time.Now().UnixNano()),
Name: req.Name,
CommonName: req.CommonName,
Organization: req.Organization,
Country: req.Country,
Province: req.Province,
Locality: req.Locality,
Email: req.Email,
PrivateKey: string(privPEM),
Certificate: string(certPEM),
SerialNumber: serialNumber.String(),
ValidFrom: template.NotBefore,
ValidTo: template.NotAfter,
ParentCAID: req.ParentCAID,
}
return subca, nil
}
func (s *CryptoService) GenerateCertificate(req models.CreateCertificateRequest, issuer interface{}) (*models.Certificate, error) {
var issuerCert *x509.Certificate
var issuerKey *rsa.PrivateKey
// Parse issuer based on type
switch iss := issuer.(type) {
case *models.CA:
certBlock, _ := pem.Decode([]byte(iss.Certificate))
if certBlock == nil {
return nil, fmt.Errorf("failed to parse issuer certificate")
}
var err error
issuerCert, err = x509.ParseCertificate(certBlock.Bytes)
if err != nil {
return nil, fmt.Errorf("failed to parse issuer certificate: %v", err)
}
keyBlock, _ := pem.Decode([]byte(iss.PrivateKey))
if keyBlock == nil {
return nil, fmt.Errorf("failed to parse issuer private key")
}
issuerKey, err = x509.ParsePKCS1PrivateKey(keyBlock.Bytes)
if err != nil {
return nil, fmt.Errorf("failed to parse issuer private key: %v", err)
}
case *models.SubCA:
certBlock, _ := pem.Decode([]byte(iss.Certificate))
if certBlock == nil {
return nil, fmt.Errorf("failed to parse issuer certificate")
}
var err error
issuerCert, err = x509.ParseCertificate(certBlock.Bytes)
if err != nil {
return nil, fmt.Errorf("failed to parse issuer certificate: %v", err)
}
keyBlock, _ := pem.Decode([]byte(iss.PrivateKey))
if keyBlock == nil {
return nil, fmt.Errorf("failed to parse issuer private key")
}
issuerKey, err = x509.ParsePKCS1PrivateKey(keyBlock.Bytes)
if err != nil {
return nil, fmt.Errorf("failed to parse issuer private key: %v", err)
}
default:
return nil, fmt.Errorf("unsupported issuer type")
}
// Generate certificate private key
priv, err := rsa.GenerateKey(rand.Reader, req.KeySize)
if err != nil {
return nil, fmt.Errorf("failed to generate private key: %v", err)
}
// Create certificate template
serialNumber, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))
if err != nil {
return nil, fmt.Errorf("failed to generate serial number: %v", err)
}
template := x509.Certificate{
SerialNumber: serialNumber,
Subject: pkix.Name{
CommonName: req.CommonName,
},
NotBefore: time.Now(),
NotAfter: time.Now().AddDate(0, 0, req.ValidDays),
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment,
ExtKeyUsage: s.getExtKeyUsage(req.Type),
DNSNames: req.DNSNames,
IPAddresses: s.parseIPs(req.IPAddresses),
}
// Sign the certificate
certBytes, err := x509.CreateCertificate(rand.Reader, &template, issuerCert, &priv.PublicKey, issuerKey)
if err != nil {
return nil, fmt.Errorf("failed to create certificate: %v", err)
}
// Encode private key
privBytes := x509.MarshalPKCS1PrivateKey(priv)
privPEM := pem.EncodeToMemory(&pem.Block{
Type: "RSA PRIVATE KEY",
Bytes: privBytes,
})
// Encode certificate
certPEM := pem.EncodeToMemory(&pem.Block{
Type: "CERTIFICATE",
Bytes: certBytes,
})
cert := &models.Certificate{
ID: fmt.Sprintf("cert_%d", time.Now().UnixNano()),
CommonName: req.CommonName,
Subject: template.Subject.String(),
DNSNames: req.DNSNames,
IPAddresses: req.IPAddresses,
Type: req.Type,
PrivateKey: string(privPEM),
Certificate: string(certPEM),
SerialNumber: serialNumber.String(),
ValidFrom: template.NotBefore,
ValidTo: template.NotAfter,
IssuerCAID: req.IssuerCAID,
Revoked: false,
}
return cert, nil
}
func (s *CryptoService) getExtKeyUsage(certType string) []x509.ExtKeyUsage {
switch certType {
case "server":
return []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}
case "client":
return []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth}
default:
return []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth, x509.ExtKeyUsageClientAuth}
}
}
func (s *CryptoService) parseIPs(ips []string) []net.IP {
var parsedIPs []net.IP
for _, ipStr := range ips {
if ip := net.ParseIP(ipStr); ip != nil {
parsedIPs = append(parsedIPs, ip)
}
}
return parsedIPs
}

View File

@@ -0,0 +1,502 @@
/* Ajoutez ces styles à la fin du fichier CSS existant */
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 20px;
margin-bottom: 30px;
}
.stat-card {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 20px;
border-radius: 10px;
text-align: center;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
}
.stat-card h3 {
font-size: 16px;
margin-bottom: 10px;
opacity: 0.9;
}
.stat-number {
font-size: 32px;
font-weight: bold;
}
.recent-activity {
background: rgba(255, 255, 255, 0.95);
padding: 20px;
border-radius: 10px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
}
.recent-activity h3 {
margin-bottom: 20px;
color: #333;
}
.table-container {
overflow-x: auto;
}
.table-container .btn {
margin-bottom: 20px;
}
/* Spinner styles */
.fa-spinner {
color: #667eea;
margin-bottom: 15px;
}
/* Modal improvements */
.modal-content {
background: white;
padding: 30px;
border-radius: 10px;
max-width: 600px;
width: 90%;
max-height: 80vh;
overflow-y: auto;
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
}
.modal-content h2 {
margin-bottom: 20px;
color: #333;
}
.modal-body {
padding: 10px 0;
}
/* Form improvements */
.form-group {
margin-bottom: 15px;
}
.form-group label {
display: block;
margin-bottom: 5px;
color: #4a5568;
font-weight: 600;
}
.form-group input,
.form-group select,
.form-group textarea {
width: 100%;
padding: 8px 12px;
border: 1px solid #cbd5e0;
border-radius: 6px;
font-size: 14px;
transition: border-color 0.2s;
}
.form-group input:focus,
.form-group select:focus,
.form-group textarea:focus {
outline: none;
border-color: #667eea;
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
}
.form-group input[type="checkbox"] {
width: auto;
margin-right: 8px;
}
/* Button improvements */
.btn {
padding: 10px 20px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border: none;
border-radius: 6px;
cursor: pointer;
font-weight: 600;
transition: all 0.3s ease;
margin-right: 10px;
}
.btn:hover {
transform: translateY(-2px);
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05);
}
.btn-danger {
background: linear-gradient(135deg, #e53e3e 0%, #c53030 100%);
}
.btn-danger:hover {
background: linear-gradient(135deg, #c53030 0%, #9b2c2c 100%);
}
/* Close button */
.close-btn {
float: right;
font-size: 28px;
font-weight: bold;
color: #a0aec0;
cursor: pointer;
line-height: 20px;
transition: color 0.2s;
}
.close-btn:hover {
color: #4a5568;
}
/* Responsive design */
@media (max-width: 768px) {
.container {
padding: 10px;
}
header {
padding: 15px;
}
nav {
flex-direction: column;
gap: 10px;
}
.nav-btn {
width: 100%;
text-align: left;
}
.main-content {
grid-template-columns: 1fr;
}
.stats-grid {
grid-template-columns: repeat(2, 1fr);
}
.modal-content {
width: 95%;
padding: 20px;
}
}
/* Badge styles */
.badge {
display: inline-block;
padding: 4px 8px;
border-radius: 4px;
font-size: 12px;
font-weight: 600;
text-transform: uppercase;
}
.badge-server {
background-color: #4299e1;
color: white;
}
.badge-client {
background-color: #48bb78;
color: white;
}
.badge-warning {
background-color: #ed8936;
color: white;
}
/* Detail view styles */
.detail-group {
margin-bottom: 15px;
padding-bottom: 15px;
border-bottom: 1px solid #e2e8f0;
}
.detail-group:last-child {
border-bottom: none;
}
.detail-group label {
display: block;
font-weight: 600;
color: #4a5568;
margin-bottom: 5px;
}
.detail-group span {
display: block;
color: #2d3748;
word-break: break-all;
}
.monospace {
font-family: 'Courier New', monospace;
background-color: #f7fafc;
padding: 4px 8px;
border-radius: 4px;
font-size: 14px;
}
.certificate-preview {
margin-top: 20px;
}
.certificate-preview label {
display: block;
font-weight: 600;
margin-bottom: 10px;
color: #4a5568;
}
.cert-pem {
width: 100%;
height: 200px;
font-family: 'Courier New', monospace;
font-size: 12px;
padding: 10px;
border: 1px solid #cbd5e0;
border-radius: 6px;
background-color: #f7fafc;
resize: vertical;
white-space: pre;
overflow-x: auto;
}
.actions {
margin-top: 20px;
display: flex;
gap: 10px;
flex-wrap: wrap;
}
/* Loading spinner */
.loading-spinner {
text-align: center;
padding: 40px;
}
.loading-spinner i {
font-size: 48px;
color: #667eea;
margin-bottom: 20px;
}
.loading-spinner p {
color: #718096;
font-size: 16px;
}
/* Empty state */
.empty-state {
text-align: center;
padding: 60px 20px;
color: #718096;
}
.empty-state i {
font-size: 48px;
margin-bottom: 20px;
color: #cbd5e0;
}
.empty-state h3 {
margin-bottom: 10px;
color: #4a5568;
}
/* Tooltip for buttons */
button[title] {
position: relative;
}
button[title]:hover::after {
content: attr(title);
position: absolute;
bottom: 100%;
left: 50%;
transform: translateX(-50%);
background-color: #2d3748;
color: white;
padding: 4px 8px;
border-radius: 4px;
font-size: 12px;
white-space: nowrap;
z-index: 1000;
margin-bottom: 5px;
}
/* Loading states */
.loading {
text-align: center;
padding: 40px;
color: #666;
}
.error {
text-align: center;
padding: 40px;
color: #e53e3e;
background: #fed7d7;
border-radius: 8px;
}
.empty-state {
text-align: center;
padding: 60px 20px;
color: #718096;
}
.empty-state p {
margin-bottom: 20px;
}
/* Section headers */
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.section-header h3 {
margin: 0;
}
/* Table improvements */
.table {
width: 100%;
border-collapse: collapse;
background: white;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
}
.table th {
background: #f7fafc;
padding: 12px 15px;
text-align: left;
font-weight: 600;
color: #4a5568;
border-bottom: 2px solid #e2e8f0;
}
.table td {
padding: 12px 15px;
border-bottom: 1px solid #e2e8f0;
}
.table tr:last-child td {
border-bottom: none;
}
.table tr:hover {
background: #f7fafc;
}
/* Status badges */
.status {
display: inline-block;
padding: 4px 8px;
border-radius: 4px;
font-size: 12px;
font-weight: 600;
text-transform: uppercase;
}
.status.valid {
background: #c6f6d5;
color: #22543d;
}
.status.revoked {
background: #fed7d7;
color: #742a2a;
}
.status.expired {
background: #feebc8;
color: #744210;
}
/* Button styles */
.btn {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 8px 16px;
border: none;
border-radius: 6px;
cursor: pointer;
font-weight: 500;
transition: all 0.2s;
}
.btn i {
font-size: 14px;
}
.btn.primary {
background: #667eea;
color: white;
}
.btn.primary:hover {
background: #5a67d8;
}
.btn.small {
padding: 4px 8px;
font-size: 13px;
}
.btn.danger {
background: #fc8181;
color: #742a2a;
}
.btn.danger:hover {
background: #f56565;
}
.btn.warning {
background: #ed8936;
color: #744210;
}
.btn.warning:hover {
background: #dd6b20;
}
.actions {
display: flex;
gap: 5px;
}
/* Modal animations */
@keyframes slideIn {
from {
transform: translateX(100%);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
@keyframes slideOut {
from {
transform: translateX(0);
opacity: 1;
}
to {
transform: translateX(100%);
opacity: 0;
}
}

View File

@@ -0,0 +1,887 @@
class PKIManager {
constructor() {
this.apiBase = '/api/v1';
this.currentTab = 'dashboard';
this.cas = [];
this.subcas = [];
this.certificates = [];
this.init();
}
init() {
this.bindEvents();
this.showTab('dashboard');
}
bindEvents() {
// Tab navigation
document.querySelectorAll('.nav-btn').forEach(btn => {
btn.addEventListener('click', (e) => {
const tab = e.target.dataset.tab || e.target.closest('.nav-btn').dataset.tab;
this.showTab(tab);
});
});
// Form submissions
const caForm = document.getElementById('createCAForm');
const subcaForm = document.getElementById('createSubCAForm');
const certForm = document.getElementById('createCertForm');
if (caForm) caForm.addEventListener('submit', (e) => this.handleCreateCA(e));
if (subcaForm) subcaForm.addEventListener('submit', (e) => this.handleCreateSubCA(e));
if (certForm) certForm.addEventListener('submit', (e) => this.handleCreateCertificate(e));
// Close modal buttons
document.addEventListener('click', (e) => {
if (e.target.classList.contains('close-btn') || e.target.closest('.close-btn')) {
this.hideModal();
}
});
// Click outside modal
window.addEventListener('click', (e) => {
if (e.target.classList.contains('modal')) {
this.hideModal();
}
});
}
showTab(tabName) {
console.log(`Showing tab: ${tabName}`);
// Hide all tabs
document.querySelectorAll('.tab-content').forEach(tab => {
tab.classList.add('hidden');
});
// Update active button
document.querySelectorAll('.nav-btn').forEach(btn => {
btn.classList.remove('active');
});
const activeBtn = document.querySelector(`[data-tab="${tabName}"]`);
if (activeBtn) activeBtn.classList.add('active');
// Show selected tab
const tabElement = document.getElementById(tabName + 'Tab');
if (tabElement) {
tabElement.classList.remove('hidden');
}
this.currentTab = tabName;
// Load data for tab
setTimeout(() => this.loadTabData(tabName), 50);
}
async loadTabData(tabName) {
switch(tabName) {
case 'dashboard':
await this.loadDashboard();
break;
case 'cas':
await this.loadCAs();
break;
case 'subcas':
await this.loadSubCAs();
break;
case 'certificates':
await this.loadCertificates();
break;
}
}
async loadDashboard() {
const content = document.getElementById('dashboardContent');
if (!content) return;
content.innerHTML = '<div class="loading">Loading dashboard...</div>';
try {
// Charger les données
await this.fetchAllData();
content.innerHTML = `
<div class="stats-grid">
<div class="stat-card">
<h3>Root CAs</h3>
<p class="stat-number">${this.cas.filter(c => c.is_root).length}</p>
</div>
<div class="stat-card">
<h3>Sub CAs</h3>
<p class="stat-number">${this.subcas.length}</p>
</div>
<div class="stat-card">
<h3>Certificates</h3>
<p class="stat-number">${this.certificates.length}</p>
</div>
<div class="stat-card">
<h3>Active</h3>
<p class="stat-number">${this.certificates.filter(c => !c.revoked && new Date(c.valid_to) > new Date()).length}</p>
</div>
</div>
<div class="recent-activity">
<h3>Recent Certificates</h3>
${this.certificates.length > 0 ? `
<table class="table">
<thead>
<tr><th>Name</th><th>Type</th><th>Issued</th><th>Expires</th><th>Status</th></tr>
</thead>
<tbody>
${this.certificates.slice(0, 5).map(cert => `
<tr>
<td>${cert.common_name}</td>
<td>${cert.type}</td>
<td>${new Date(cert.created_at).toLocaleDateString()}</td>
<td>${new Date(cert.valid_to).toLocaleDateString()}</td>
<td><span class="status ${cert.revoked ? 'revoked' : new Date(cert.valid_to) > new Date() ? 'valid' : 'expired'}">
${cert.revoked ? 'Revoked' : new Date(cert.valid_to) > new Date() ? 'Valid' : 'Expired'}
</span></td>
</tr>
`).join('')}
</tbody>
</table>
` : '<p>No certificates yet</p>'}
</div>
`;
} catch (error) {
console.error('Dashboard error:', error);
content.innerHTML = '<div class="error">Failed to load dashboard</div>';
}
}
async loadCAs() {
const content = document.getElementById('casContent');
if (!content) return;
content.innerHTML = '<div class="loading">Loading CAs...</div>';
try {
await this.fetchCAs();
content.innerHTML = `
<div class="section-header">
<h3>Certificate Authorities</h3>
<button class="btn primary" onclick="pki.showCreateCAModal()">
<i class="fas fa-plus"></i> New CA
</button>
</div>
${this.cas.length > 0 ? `
<table class="table">
<thead>
<tr>
<th>Name</th>
<th>Common Name</th>
<th>Organization</th>
<th>Valid From</th>
<th>Valid To</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
${this.cas.map(ca => `
<tr>
<td>${ca.name}</td>
<td>${ca.common_name}</td>
<td>${ca.organization}</td>
<td>${new Date(ca.valid_from).toLocaleDateString()}</td>
<td>${new Date(ca.valid_to).toLocaleDateString()}</td>
<td class="actions">
<button class="btn small" onclick="pki.viewCA('${ca.id}')">View</button>
<button class="btn small danger" onclick="pki.deleteCA('${ca.id}')">Delete</button>
</td>
</tr>
`).join('')}
</tbody>
</table>
` : `
<div class="empty-state">
<p>No Certificate Authorities found</p>
<button class="btn primary" onclick="pki.showCreateCAModal()">
Create your first CA
</button>
</div>
`}
`;
} catch (error) {
console.error('CAs error:', error);
content.innerHTML = '<div class="error">Failed to load CAs</div>';
}
}
async loadSubCAs() {
const content = document.getElementById('subcasContent');
if (!content) return;
content.innerHTML = '<div class="loading">Loading Sub CAs...</div>';
try {
await this.fetchSubCAs();
await this.fetchCAs(); // Need CAs for parent info
content.innerHTML = `
<div class="section-header">
<h3>Sub Certificate Authorities</h3>
<button class="btn primary" onclick="pki.showCreateSubCAModal()">
<i class="fas fa-plus"></i> New Sub CA
</button>
</div>
${this.subcas.length > 0 ? `
<table class="table">
<thead>
<tr>
<th>Name</th>
<th>Common Name</th>
<th>Parent CA</th>
<th>Valid From</th>
<th>Valid To</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
${this.subcas.map(subca => {
const parent = this.cas.find(ca => ca.id === subca.parent_ca_id);
return `
<tr>
<td>${subca.name}</td>
<td>${subca.common_name}</td>
<td>${parent ? parent.name : 'Unknown'}</td>
<td>${new Date(subca.valid_from).toLocaleDateString()}</td>
<td>${new Date(subca.valid_to).toLocaleDateString()}</td>
<td class="actions">
<button class="btn small" onclick="pki.viewSubCA('${subca.id}')">View</button>
<button class="btn small danger" onclick="pki.deleteSubCA('${subca.id}')">Delete</button>
</td>
</tr>
`;
}).join('')}
</tbody>
</table>
` : `
<div class="empty-state">
<p>No Sub Certificate Authorities found</p>
${this.cas.length > 0 ? `
<button class="btn primary" onclick="pki.showCreateSubCAModal()">
Create your first Sub CA
</button>
` : '<p>Create a CA first to create Sub CAs</p>'}
</div>
`}
`;
} catch (error) {
console.error('SubCAs error:', error);
content.innerHTML = '<div class="error">Failed to load Sub CAs</div>';
}
}
async loadCertificates() {
const content = document.getElementById('certsContent');
if (!content) return;
content.innerHTML = '<div class="loading">Loading Certificates...</div>';
try {
await this.fetchCertificates();
await this.fetchCAs();
await this.fetchSubCAs();
content.innerHTML = `
<div class="section-header">
<h3>Certificates</h3>
<button class="btn primary" onclick="pki.showCreateCertModal()">
<i class="fas fa-plus"></i> New Certificate
</button>
</div>
${this.certificates.length > 0 ? `
<table class="table">
<thead>
<tr>
<th>Common Name</th>
<th>Type</th>
<th>Issuer</th>
<th>Issued</th>
<th>Expires</th>
<th>Status</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
${this.certificates.map(cert => {
const issuer = [...this.cas, ...this.subcas].find(i => i.id === cert.issuer_ca_id);
const status = cert.revoked ? 'revoked' : new Date(cert.valid_to) > new Date() ? 'valid' : 'expired';
return `
<tr>
<td>${cert.common_name}</td>
<td>${cert.type}</td>
<td>${issuer ? issuer.name : 'Unknown'}</td>
<td>${new Date(cert.created_at).toLocaleDateString()}</td>
<td>${new Date(cert.valid_to).toLocaleDateString()}</td>
<td><span class="status ${status}">${status.charAt(0).toUpperCase() + status.slice(1)}</span></td>
<td class="actions">
<button class="btn small" onclick="pki.viewCertificate('${cert.id}')">View</button>
<button class="btn small" onclick="pki.downloadCertificate('${cert.id}')">Download</button>
${!cert.revoked ? `<button class="btn small warning" onclick="pki.revokeCertificate('${cert.id}')">Revoke</button>` : ''}
<button class="btn small danger" onclick="pki.deleteCertificate('${cert.id}')">Delete</button>
</td>
</tr>
`;
}).join('')}
</tbody>
</table>
` : `
<div class="empty-state">
<p>No certificates found</p>
${this.cas.length > 0 || this.subcas.length > 0 ? `
<button class="btn primary" onclick="pki.showCreateCertModal()">
Create your first certificate
</button>
` : '<p>Create a CA or Sub CA first</p>'}
</div>
`}
`;
} catch (error) {
console.error('Certificates error:', error);
content.innerHTML = '<div class="error">Failed to load certificates</div>';
}
}
async fetchAllData() {
try {
const [cas, subcas, certs] = await Promise.all([
this.fetchCAs(),
this.fetchSubCAs(),
this.fetchCertificates()
]);
return { cas, subcas, certs };
} catch (error) {
console.error('Error fetching all data:', error);
throw error;
}
}
async fetchCAs() {
try {
console.log('Fetching CAs from:', `${this.apiBase}/cas`);
const response = await fetch(`${this.apiBase}/cas`);
console.log('CAs response status:', response.status);
if (!response.ok) {
console.warn(`CAs fetch failed: ${response.status} ${response.statusText}`);
this.cas = [];
return this.cas;
}
const data = await response.json();
console.log('CAs raw data:', data);
// S'assurer que c'est un tableau
this.cas = Array.isArray(data) ? data : [];
console.log(`Loaded ${this.cas.length} CAs`);
return this.cas;
} catch (error) {
console.error('CAs fetch error:', error);
this.cas = [];
return this.cas;
}
}
async fetchSubCAs() {
try {
console.log('Fetching SubCAs from:', `${this.apiBase}/subcas`);
const response = await fetch(`${this.apiBase}/subcas`);
console.log('SubCAs response status:', response.status);
if (!response.ok) {
console.warn(`SubCAs fetch failed: ${response.status} ${response.statusText}`);
this.subcas = [];
return this.subcas;
}
const data = await response.json();
console.log('SubCAs raw data:', data);
// S'assurer que c'est un tableau
this.subcas = Array.isArray(data) ? data : [];
console.log(`Loaded ${this.subcas.length} SubCAs`);
return this.subcas;
} catch (error) {
console.error('SubCAs fetch error:', error);
this.subcas = [];
return this.subcas;
}
}
async fetchCertificates() {
try {
console.log('Fetching certificates...');
const response = await fetch(`${this.apiBase}/certificates`);
if (!response.ok) {
console.warn(`Certificates fetch failed: ${response.status}`);
this.certificates = [];
return this.certificates;
}
const data = await response.json();
this.certificates = Array.isArray(data) ? data : [];
console.log(`Loaded ${this.certificates.length} certificates`);
return this.certificates;
} catch (error) {
console.warn('Certificates fetch error:', error);
this.certificates = [];
return this.certificates;
}
}
async showCreateCAModal() {
const modal = document.getElementById('createCAModal');
if (modal) modal.classList.remove('hidden');
}
async showCreateSubCAModal() {
try {
// Charger les CAs pour le dropdown
await this.fetchCAs();
if (this.cas.length === 0) {
this.showError('Create a CA first before creating a Sub CA');
return;
}
// Mettre à jour le dropdown
const dropdown = document.getElementById('parentCA');
if (dropdown) {
dropdown.innerHTML = this.cas.map(ca =>
`<option value="${ca.id}">${ca.name} (${ca.common_name})</option>`
).join('');
}
const modal = document.getElementById('createSubCAModal');
if (modal) modal.classList.remove('hidden');
} catch (error) {
console.error('Error showing SubCA modal:', error);
this.showError('Failed to load CAs');
}
}
async showCreateCertModal() {
console.log('=== showCreateCertModal START ===');
try {
// 1. Charger les données
console.log('Loading issuers...');
await this.fetchCAs();
await this.fetchSubCAs();
// 2. Vérifier les données
console.log(`CAs: ${this.cas.length} items`, this.cas);
console.log(`SubCAs: ${this.subcas.length} items`, this.subcas);
// 3. Combiner tous les émetteurs
const allIssuers = [];
// Ajouter les CAs
if (this.cas && Array.isArray(this.cas)) {
this.cas.forEach(ca => {
if (ca && ca.id) {
allIssuers.push({
id: ca.id,
name: ca.name || ca.common_name || 'Unnamed CA',
common_name: ca.common_name || 'No CN',
type: 'CA'
});
}
});
}
// Ajouter les SubCAs
if (this.subcas && Array.isArray(this.subcas)) {
this.subcas.forEach(subca => {
if (subca && subca.id) {
allIssuers.push({
id: subca.id,
name: subca.name || subca.common_name || 'Unnamed SubCA',
common_name: subca.common_name || 'No CN',
type: 'SubCA'
});
}
});
}
console.log('All issuers combined:', allIssuers);
if (allIssuers.length === 0) {
this.showError('Please create a CA or Sub CA first.');
return;
}
// 4. Remplir le dropdown
const dropdown = document.getElementById('issuerCA');
if (!dropdown) {
console.error('ERROR: Dropdown #issuerCA not found in DOM!');
// Vérifier si l'élément existe avec un autre ID
console.log('Available select elements:', document.querySelectorAll('select'));
return;
}
console.log('Dropdown found, populating...');
// Sauvegarder la sélection actuelle
const currentValue = dropdown.value;
// Vider et remplir
dropdown.innerHTML = '<option value="">-- Select Issuer --</option>';
allIssuers.forEach(issuer => {
const option = document.createElement('option');
option.value = issuer.id;
option.textContent = `${issuer.name} (${issuer.type})`;
dropdown.appendChild(option);
});
// Restaurer la sélection si possible
if (currentValue && allIssuers.some(i => i.id === currentValue)) {
dropdown.value = currentValue;
}
console.log(`Dropdown populated with ${allIssuers.length} options`);
console.log('Dropdown HTML:', dropdown.innerHTML);
// 5. Afficher la modal
const modal = document.getElementById('createCertModal');
if (modal) {
modal.classList.remove('hidden');
console.log('Modal shown');
// Focus sur le premier champ
setTimeout(() => {
const firstInput = modal.querySelector('input, select');
if (firstInput) firstInput.focus();
}, 100);
} else {
console.error('ERROR: Modal #createCertModal not found!');
}
} catch (error) {
console.error('Error in showCreateCertModal:', error);
this.showError('Failed to open certificate form: ' + error.message);
}
console.log('=== showCreateCertModal END ===');
}
hideModal() {
document.querySelectorAll('.modal').forEach(modal => {
modal.classList.add('hidden');
});
}
async handleCreateCA(e) {
e.preventDefault();
const formData = new FormData(e.target);
const data = Object.fromEntries(formData.entries());
try {
const response = await fetch(`${this.apiBase}/cas`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: data.name,
common_name: data.common_name,
organization: data.organization,
country: data.country,
province: data.province || '',
locality: data.locality || '',
email: data.email || '',
key_size: parseInt(data.key_size) || 4096,
valid_years: parseInt(data.valid_years) || 10,
is_root: data.is_root === 'true'
})
});
if (response.ok) {
this.showSuccess('CA created successfully');
this.hideModal();
e.target.reset();
await this.fetchAllData();
this.showTab(this.currentTab);
} else {
const error = await response.json();
throw new Error(error.error || 'Failed to create CA');
}
} catch (error) {
console.error('CA creation error:', error);
this.showError('Failed to create CA: ' + error.message);
}
}
async handleCreateSubCA(e) {
e.preventDefault();
const formData = new FormData(e.target);
const data = Object.fromEntries(formData.entries());
try {
const response = await fetch(`${this.apiBase}/subcas`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: data.name,
common_name: data.common_name,
organization: data.organization,
email: data.email || '',
country: data.country,
province: data.province || '',
locality: data.locality || '',
parent_ca_id: data.parent_ca_id,
key_size: parseInt(data.key_size) || 4096,
valid_years: parseInt(data.valid_years) || 5
})
});
if (response.ok) {
this.showSuccess('Sub CA created successfully');
this.hideModal();
e.target.reset();
await this.fetchAllData();
this.showTab(this.currentTab);
} else {
const error = await response.json();
throw new Error(error.error || 'Failed to create Sub CA');
}
} catch (error) {
console.error('SubCA creation error:', error);
this.showError('Failed to create Sub CA: ' + error.message);
}
}
async handleCreateCertificate(e) {
e.preventDefault();
const formData = new FormData(e.target);
const data = Object.fromEntries(formData.entries());
try {
const dnsNames = data.dns_names ? data.dns_names.split(',').map(s => s.trim()).filter(s => s) : [];
const ipAddresses = data.ip_addresses ? data.ip_addresses.split(',').map(s => s.trim()).filter(s => s) : [];
const response = await fetch(`${this.apiBase}/certificates`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
common_name: data.common_name,
type: data.type,
dns_names: dnsNames,
ip_addresses: ipAddresses,
issuer_ca_id: data.issuer_ca_id,
key_size: parseInt(data.key_size) || 2048,
valid_days: parseInt(data.valid_days) || 365
})
});
if (response.ok) {
this.showSuccess('Certificate created successfully');
this.hideModal();
e.target.reset();
await this.fetchAllData();
this.showTab(this.currentTab);
} else {
const error = await response.json();
throw new Error(error.error || 'Failed to create certificate');
}
} catch (error) {
console.error('Certificate creation error:', error);
this.showError('Failed to create certificate: ' + error.message);
}
}
async deleteCA(id) {
if (!confirm('Delete this CA?')) return;
try {
const response = await fetch(`${this.apiBase}/cas/${id}`, { method: 'DELETE' });
if (response.ok) {
this.showSuccess('CA deleted');
await this.fetchAllData();
this.showTab(this.currentTab);
} else {
throw new Error('Delete failed');
}
} catch (error) {
this.showError('Failed to delete CA');
}
}
async deleteSubCA(id) {
if (!confirm('Delete this Sub CA?')) return;
try {
const response = await fetch(`${this.apiBase}/subcas/${id}`, { method: 'DELETE' });
if (response.ok) {
this.showSuccess('Sub CA deleted');
await this.fetchAllData();
this.showTab(this.currentTab);
} else {
throw new Error('Delete failed');
}
} catch (error) {
this.showError('Failed to delete Sub CA');
}
}
async deleteCertificate(id) {
if (!confirm('Delete this certificate?')) return;
try {
const response = await fetch(`${this.apiBase}/certificates/${id}`, { method: 'DELETE' });
if (response.ok) {
this.showSuccess('Certificate deleted');
await this.fetchAllData();
this.showTab(this.currentTab);
} else {
throw new Error('Delete failed');
}
} catch (error) {
this.showError('Failed to delete certificate');
}
}
async revokeCertificate(id) {
const reason = prompt('Revocation reason:') || 'Administrative revocation';
if (!reason) return;
try {
const response = await fetch(`${this.apiBase}/certificates/${id}/revoke`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ reason })
});
if (response.ok) {
this.showSuccess('Certificate revoked');
await this.fetchAllData();
this.showTab(this.currentTab);
} else {
throw new Error('Revocation failed');
}
} catch (error) {
this.showError('Failed to revoke certificate');
}
}
downloadCertificate(id) {
window.open(`${this.apiBase}/certificates/${id}/download/cert`, '_blank');
}
async viewCA(id) {
try {
const response = await fetch(`${this.apiBase}/cas/${id}`);
if (!response.ok) throw new Error('Not found');
const ca = await response.json();
this.showModal('CA Details', `
<h3>${ca.name}</h3>
<p><strong>Common Name:</strong> ${ca.common_name}</p>
<p><strong>Organization:</strong> ${ca.organization}</p>
<p><strong>Valid From:</strong> ${new Date(ca.valid_from).toLocaleString()}</p>
<p><strong>Valid To:</strong> ${new Date(ca.valid_to).toLocaleString()}</p>
<p><strong>Serial:</strong> ${ca.serial_number}</p>
<button class="btn" onclick="window.open('${this.apiBase}/cas/${id}/download/cert')">Download Certificate</button>
`);
} catch (error) {
this.showError('Failed to load CA details');
}
}
async viewSubCA(id) {
try {
const response = await fetch(`${this.apiBase}/subcas/${id}`);
if (!response.ok) throw new Error('Not found');
const subca = await response.json();
this.showModal('Sub CA Details', `
<h3>${subca.name}</h3>
<p><strong>Common Name:</strong> ${subca.common_name}</p>
<p><strong>Organization:</strong> ${subca.organization}</p>
<p><strong>Valid From:</strong> ${new Date(subca.valid_from).toLocaleString()}</p>
<p><strong>Valid To:</strong> ${new Date(subca.valid_to).toLocaleString()}</p>
<p><strong>Serial:</strong> ${subca.serial_number}</p>
<button class="btn" onclick="window.open('${this.apiBase}/subcas/${id}/download/cert')">Download Certificate</button>
`);
} catch (error) {
this.showError('Failed to load Sub CA details');
}
}
async viewCertificate(id) {
try {
const response = await fetch(`${this.apiBase}/certificates/${id}`);
if (!response.ok) throw new Error('Not found');
const cert = await response.json();
this.showModal('Certificate Details', `
<h3>${cert.common_name}</h3>
<p><strong>Type:</strong> ${cert.type}</p>
<p><strong>Valid From:</strong> ${new Date(cert.valid_from).toLocaleString()}</p>
<p><strong>Valid To:</strong> ${new Date(cert.valid_to).toLocaleString()}</p>
<p><strong>Status:</strong> ${cert.revoked ? 'Revoked' : new Date(cert.valid_to) > new Date() ? 'Valid' : 'Expired'}</p>
<p><strong>Serial:</strong> ${cert.serial_number}</p>
<div class="button-group">
<button class="btn" onclick="window.open('${this.apiBase}/certificates/${id}/download/cert')">Download Certificate</button>
<button class="btn" onclick="window.open('${this.apiBase}/certificates/${id}/download/key')">Download Private Key</button>
</div>
`);
} catch (error) {
this.showError('Failed to load certificate details');
}
}
showModal(title, content) {
const modal = document.getElementById('viewModal');
if (!modal) return;
modal.querySelector('h2').textContent = title;
modal.querySelector('.modal-body').innerHTML = content;
modal.classList.remove('hidden');
}
showSuccess(message) {
this.showAlert(message, 'success');
}
showError(message) {
this.showAlert(message, 'error');
}
showAlert(message, type) {
// Remove existing alerts
document.querySelectorAll('.alert').forEach(a => a.remove());
const alert = document.createElement('div');
alert.className = `alert alert-${type}`;
alert.textContent = message;
alert.style.cssText = `
position: fixed;
top: 20px;
right: 20px;
padding: 15px 20px;
border-radius: 5px;
z-index: 10000;
animation: slideIn 0.3s ease;
`;
document.body.appendChild(alert);
setTimeout(() => {
alert.style.animation = 'slideOut 0.3s ease';
setTimeout(() => alert.remove(), 300);
}, 5000);
}
}
// Initialize when DOM is ready
document.addEventListener('DOMContentLoaded', () => {
window.pki = new PKIManager();
});

View File

@@ -0,0 +1,307 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>PKI Manager</title>
<link rel="stylesheet" href="/static/css/style.css">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css">
</head>
<body>
<div class="container">
<header>
<h1><i class="fas fa-shield-alt"></i> PKI Manager</h1>
<p>Manage your Certificate Authority infrastructure</p>
<div style="margin-bottom: 10px;">
<button class="btn" onclick="pki.debugData()" style="background: #38a169;">
<i class="fas fa-bug"></i> Debug Data
</button>
<button onclick="testDropdown()" style="padding: 8px 12px; background: #4CAF50; color: white; border: none; border-radius: 4px; cursor: pointer;">
Test Dropdown
</button>
<script>
function testDropdown() {
console.log('=== TEST DROPDOWN ===');
const dropdown = document.getElementById('issuerCA');
console.log('Dropdown:', dropdown);
console.log('Parent:', dropdown?.parentElement);
console.log('All selects:', document.querySelectorAll('select'));
// Simuler le remplissage
if (dropdown) {
dropdown.innerHTML = '';
const option = document.createElement('option');
option.value = 'test';
option.textContent = 'Test Option';
dropdown.appendChild(option);
console.log('Test option added');
}
}
</script>
</div>
<nav>
<button class="nav-btn" data-tab="dashboard">
<i class="fas fa-home"></i> Dashboard
</button>
<button class="nav-btn" data-tab="cas">
<i class="fas fa-certificate"></i> CAs
</button>
<button class="nav-btn" data-tab="subcas">
<i class="fas fa-sitemap"></i> Sub CAs
</button>
<button class="nav-btn" data-tab="certificates">
<i class="fas fa-key"></i> Certificates
</button>
</nav>
</header>
<main>
<!-- Dashboard Tab -->
<div id="dashboardTab" class="tab-content">
<div id="dashboardContent">
<!-- Dashboard content will be loaded here -->
<div style="text-align: center; padding: 50px;">
<i class="fas fa-spinner fa-spin fa-2x"></i>
<p>Loading dashboard...</p>
</div>
</div>
</div>
<!-- CAs Tab -->
<div id="casTab" class="tab-content hidden">
<div id="casContent">
<div style="text-align: center; padding: 50px;">
<i class="fas fa-spinner fa-spin fa-2x"></i>
<p>Loading CAs...</p>
</div>
</div>
</div>
<!-- Sub CAs Tab -->
<div id="subcasTab" class="tab-content hidden">
<div id="subcasContent">
<div style="text-align: center; padding: 50px;">
<i class="fas fa-spinner fa-spin fa-2x"></i>
<p>Loading Sub CAs...</p>
</div>
</div>
</div>
<!-- Certificates Tab -->
<div id="certificatesTab" class="tab-content hidden">
<div id="certsContent">
<div style="text-align: center; padding: 50px;">
<i class="fas fa-spinner fa-spin fa-2x"></i>
<p>Loading Certificates...</p>
</div>
</div>
</div>
</main>
</div>
<!-- Create CA Modal -->
<div id="createCAModal" class="modal hidden">
<div class="modal-content">
<button class="close-btn" onclick="pki.hideModal()" style="background: none; border: none; float: right; font-size: 24px; cursor: pointer;">
<i class="fas fa-times"></i>
</button>
<h2>Create Root CA</h2>
<form id="createCAForm">
<div class="form-group">
<label for="caName">Name</label>
<input type="text" id="caName" name="name" required>
</div>
<div class="form-group">
<label for="caCommonName">Common Name</label>
<input type="text" id="caCommonName" name="common_name" required>
</div>
<div class="form-group">
<label for="caOrganization">Organization</label>
<input type="text" id="caOrganization" name="organization" required>
</div>
<div class="form-group">
<label for="caCountry">Country (2 letters)</label>
<input type="text" id="caCountry" name="country" maxlength="2" required>
</div>
<div class="form-group">
<label for="caProvince">Province/State</label>
<input type="text" id="caProvince" name="province">
</div>
<div class="form-group">
<label for="caLocality">Locality/City</label>
<input type="text" id="caLocality" name="locality">
</div>
<div class="form-group">
<label for="caEmail">Email</label>
<input type="email" id="caEmail" name="email" required>
</div>
<div class="form-group">
<label for="caKeySize">Key Size</label>
<select id="caKeySize" name="key_size" required>
<option value="2048">2048 bits</option>
<option value="4096" selected>4096 bits</option>
</select>
</div>
<div class="form-group">
<label for="caValidYears">Validity (Years)</label>
<input type="number" id="caValidYears" name="valid_years" value="10" min="1" max="20" required>
</div>
<div class="form-group">
<label>
<input type="checkbox" id="caIsRoot" name="is_root" value="true" checked>
Is Root CA
</label>
</div>
<button type="submit" class="btn">Create CA</button>
</form>
</div>
</div>
<!-- Create Sub CA Modal -->
<div id="createSubCAModal" class="modal hidden">
<div class="modal-content">
<button class="close-btn" onclick="pki.hideModal()" style="background: none; border: none; float: right; font-size: 24px; cursor: pointer;">
<i class="fas fa-times"></i>
</button>
<h2>Create Sub CA</h2>
<form id="createSubCAForm">
<div class="form-group">
<label for="subcaName">Name</label>
<input type="text" id="subcaName" name="name" required>
</div>
<div class="form-group">
<label for="subcaCommonName">Common Name</label>
<input type="text" id="subcaCommonName" name="common_name" required>
</div>
<div class="form-group">
<label for="subcaOrganization">Organization</label>
<input type="text" id="subcaOrganization" name="organization" required>
</div>
<div class="form-group">
<label for="subcaEmail">Email (optional)</label>
<input type="email" id="subcaEmail" name="email" placeholder="Optional email address">
</div>
<div class="form-group">
<label for="subcaCountry">Country (2 letters)</label>
<input type="text" id="subcaCountry" name="country" maxlength="2" required>
</div>
<div class="form-group">
<label for="subcaProvince">Province/State</label>
<input type="text" id="subcaProvince" name="province">
</div>
<div class="form-group">
<label for="subcaLocality">Locality/City</label>
<input type="text" id="subcaLocality" name="locality">
</div>
<div class="form-group">
<label for="subcaParentCA">Parent CA</label>
<select id="parentCA" name="parent_ca_id" required></select>
</div>
<div class="form-group">
<label for="subcaKeySize">Key Size</label>
<select id="subcaKeySize" name="key_size" required>
<option value="2048">2048 bits</option>
<option value="4096" selected>4096 bits</option>
</select>
</div>
<div class="form-group">
<label for="subcaValidYears">Validity (Years)</label>
<input type="number" id="subcaValidYears" name="valid_years" value="5" min="1" max="10" required>
</div>
<button type="submit" class="btn">Create Sub CA</button>
</form>
</div>
</div>
<!-- Create Certificate Modal -->
<div id="createCertModal" class="modal hidden">
<div class="modal-content" style="max-width: 600px;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px;">
<h2 style="margin: 0;">Create Certificate</h2>
<button class="close-btn" onclick="pki.hideModal()"
style="background: none; border: none; font-size: 24px; cursor: pointer; color: #666;">
<i class="fas fa-times"></i>
</button>
</div>
<form id="createCertForm">
<div class="form-group">
<label for="certCommonName">Common Name *</label>
<input type="text" id="certCommonName" name="common_name" required
placeholder="e.g., server.example.com">
</div>
<div class="form-group">
<label for="certType">Certificate Type *</label>
<select id="certType" name="type" required>
<option value="">-- Select Type --</option>
<option value="server">Server Certificate</option>
<option value="client">Client Certificate</option>
</select>
</div>
<!-- IMPORTANT: Ce dropdown doit avoir l'ID "issuerCA" -->
<div class="form-group">
<label for="issuerCA">Issuer (CA or Sub CA) *</label>
<select id="issuerCA" name="issuer_ca_id" required
style="width: 100%; padding: 10px; border: 1px solid #ddd; border-radius: 4px;">
<option value="">-- Loading issuers... --</option>
</select>
</div>
<div class="form-group">
<label for="certDNSNames">DNS Names (optional, comma-separated)</label>
<input type="text" id="certDNSNames" name="dns_names"
placeholder="e.g., example.com, www.example.com">
</div>
<div class="form-group">
<label for="certIPs">IP Addresses (optional, comma-separated)</label>
<input type="text" id="certIPs" name="ip_addresses"
placeholder="e.g., 192.168.1.1, 10.0.0.1">
</div>
<div class="form-group">
<label for="certKeySize">Key Size *</label>
<select id="certKeySize" name="key_size" required>
<option value="2048">2048 bits (Recommended)</option>
<option value="4096">4096 bits (High Security)</option>
</select>
</div>
<div class="form-group">
<label for="certValidDays">Validity Period (Days) *</label>
<input type="number" id="certValidDays" name="valid_days"
value="365" min="1" max="3650" required>
</div>
<div style="display: flex; gap: 10px; margin-top: 30px;">
<button type="submit" class="btn"
style="flex: 1; padding: 12px; background: #667eea; color: white;">
<i class="fas fa-plus"></i> Create Certificate
</button>
<button type="button" class="btn" onclick="pki.hideModal()"
style="padding: 12px; background: #ccc; color: #333;">
Cancel
</button>
</div>
</form>
</div>
</div>
<!-- View Details Modal -->
<div id="viewModal" class="modal hidden">
<div class="modal-content">
<button class="close-btn" onclick="pki.hideModal()" style="background: none; border: none; float: right; font-size: 24px; cursor: pointer;">
<i class="fas fa-times"></i>
</button>
<h2>View Details</h2>
<div class="modal-body">
<!-- Content will be loaded here -->
</div>
</div>
</div>
<script src="/static/js/app.js"></script>
</body>
</html>