diff --git a/.gitignore b/.gitignore
index ad2645f..5f7253d 100644
--- a/.gitignore
+++ b/.gitignore
@@ -6,7 +6,10 @@
*.so.*
*.dylib
bin/
-dist/
+
+# Frontend
+web/node_modules/
+web/dist/
# Test binary, built with `go test -c`
*.test
diff --git a/cmd/server/main.go b/cmd/server/main.go
index 51d4c61..08655df 100644
--- a/cmd/server/main.go
+++ b/cmd/server/main.go
@@ -174,10 +174,15 @@ func main() {
middleware.Recovery,
)
- // Wrap with dashboard static file serving if web/ directory exists
+ // Wrap with dashboard static file serving
+ // Vite builds to web/dist/; fall back to web/ for legacy single-file SPA
var finalHandler http.Handler
- webDir := "./web"
- if _, err := os.Stat(webDir); err == nil {
+ webDir := "./web/dist"
+ if _, err := os.Stat(webDir + "/index.html"); err != nil {
+ webDir = "./web"
+ }
+ if _, err := os.Stat(webDir + "/index.html"); err == nil {
+ fileServer := http.FileServer(http.Dir(webDir))
finalHandler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
path := r.URL.Path
// API and health routes go to the API handler
@@ -186,10 +191,15 @@ func main() {
apiHandler.ServeHTTP(w, r)
return
}
- // Serve the dashboard SPA index.html for everything else
+ // Try to serve static files (JS, CSS, assets)
+ if len(path) > 8 && path[:8] == "/assets/" {
+ fileServer.ServeHTTP(w, r)
+ return
+ }
+ // SPA fallback: serve index.html for all other routes
http.ServeFile(w, r, webDir+"/index.html")
})
- logger.Info("dashboard available at /")
+ logger.Info("dashboard available at /", "web_dir", webDir)
} else {
finalHandler = apiHandler
logger.Info("dashboard directory not found, serving API only")
diff --git a/internal/api/handler/agents.go b/internal/api/handler/agents.go
index 00c8170..94d0ce7 100644
--- a/internal/api/handler/agents.go
+++ b/internal/api/handler/agents.go
@@ -117,6 +117,20 @@ func (h AgentHandler) RegisterAgent(w http.ResponseWriter, r *http.Request) {
return
}
+ // Validate required fields
+ if err := ValidateRequired("name", agent.Name); err != nil {
+ ErrorWithRequestID(w, http.StatusBadRequest, err.Error(), requestID)
+ return
+ }
+ if err := ValidateStringLength("name", agent.Name, 128); err != nil {
+ ErrorWithRequestID(w, http.StatusBadRequest, err.Error(), requestID)
+ return
+ }
+ if err := ValidateRequired("hostname", agent.Hostname); err != nil {
+ ErrorWithRequestID(w, http.StatusBadRequest, err.Error(), requestID)
+ return
+ }
+
created, err := h.svc.RegisterAgent(agent)
if err != nil {
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to register agent", requestID)
@@ -186,8 +200,9 @@ func (h AgentHandler) AgentCSRSubmit(w http.ResponseWriter, r *http.Request) {
return
}
- if req.CSRPEM == "" {
- ErrorWithRequestID(w, http.StatusBadRequest, "CSR PEM is required", requestID)
+ // Validate CSR PEM
+ if err := ValidateCSRPEM(req.CSRPEM); err != nil {
+ ErrorWithRequestID(w, http.StatusBadRequest, err.Error(), requestID)
return
}
diff --git a/internal/api/handler/certificate_handler_test.go b/internal/api/handler/certificate_handler_test.go
index 84a8fb9..dfc5860 100644
--- a/internal/api/handler/certificate_handler_test.go
+++ b/internal/api/handler/certificate_handler_test.go
@@ -305,6 +305,9 @@ func TestCreateCertificate_Success(t *testing.T) {
certBody := domain.ManagedCertificate{
Name: "Production Cert",
CommonName: "example.com",
+ OwnerID: "o-alice",
+ TeamID: "t-platform",
+ IssuerID: "iss-local",
}
body, _ := json.Marshal(certBody)
@@ -359,6 +362,9 @@ func TestCreateCertificate_ServiceError(t *testing.T) {
certBody := domain.ManagedCertificate{
Name: "Production Cert",
CommonName: "example.com",
+ OwnerID: "o-alice",
+ TeamID: "t-platform",
+ IssuerID: "iss-local",
}
body, _ := json.Marshal(certBody)
diff --git a/internal/api/handler/certificates.go b/internal/api/handler/certificates.go
index a26d503..545e961 100644
--- a/internal/api/handler/certificates.go
+++ b/internal/api/handler/certificates.go
@@ -120,6 +120,28 @@ func (h CertificateHandler) CreateCertificate(w http.ResponseWriter, r *http.Req
return
}
+ // Validate required fields
+ if err := ValidateRequired("common_name", cert.CommonName); err != nil {
+ ErrorWithRequestID(w, http.StatusBadRequest, err.Error(), requestID)
+ return
+ }
+ if err := ValidateCommonName(cert.CommonName); err != nil {
+ ErrorWithRequestID(w, http.StatusBadRequest, err.Error(), requestID)
+ return
+ }
+ if err := ValidateRequired("owner_id", cert.OwnerID); err != nil {
+ ErrorWithRequestID(w, http.StatusBadRequest, err.Error(), requestID)
+ return
+ }
+ if err := ValidateRequired("team_id", cert.TeamID); err != nil {
+ ErrorWithRequestID(w, http.StatusBadRequest, err.Error(), requestID)
+ return
+ }
+ if err := ValidateRequired("issuer_id", cert.IssuerID); err != nil {
+ ErrorWithRequestID(w, http.StatusBadRequest, err.Error(), requestID)
+ return
+ }
+
created, err := h.svc.CreateCertificate(cert)
if err != nil {
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to create certificate", requestID)
@@ -153,6 +175,26 @@ func (h CertificateHandler) UpdateCertificate(w http.ResponseWriter, r *http.Req
return
}
+ // Validate required fields (if provided)
+ if cert.CommonName != "" {
+ if err := ValidateCommonName(cert.CommonName); err != nil {
+ ErrorWithRequestID(w, http.StatusBadRequest, err.Error(), requestID)
+ return
+ }
+ }
+ if cert.OwnerID != "" {
+ if err := ValidateStringLength("owner_id", cert.OwnerID, 255); err != nil {
+ ErrorWithRequestID(w, http.StatusBadRequest, err.Error(), requestID)
+ return
+ }
+ }
+ if cert.TeamID != "" {
+ if err := ValidateStringLength("team_id", cert.TeamID, 255); err != nil {
+ ErrorWithRequestID(w, http.StatusBadRequest, err.Error(), requestID)
+ return
+ }
+ }
+
updated, err := h.svc.UpdateCertificate(id, cert)
if err != nil {
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to update certificate", requestID)
@@ -290,7 +332,11 @@ func (h CertificateHandler) TriggerDeployment(w http.ResponseWriter, r *http.Req
TargetID string `json:"target_id,omitempty"`
}
if r.Header.Get("Content-Type") == "application/json" {
- json.NewDecoder(r.Body).Decode(&req)
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ // Log but don't fail - targetID is optional
+ ErrorWithRequestID(w, http.StatusBadRequest, "Invalid request body", requestID)
+ return
+ }
}
if err := h.svc.TriggerDeployment(certID, req.TargetID); err != nil {
diff --git a/internal/api/handler/issuers.go b/internal/api/handler/issuers.go
index 2ff72e5..7be8eb0 100644
--- a/internal/api/handler/issuers.go
+++ b/internal/api/handler/issuers.go
@@ -111,6 +111,20 @@ func (h IssuerHandler) CreateIssuer(w http.ResponseWriter, r *http.Request) {
return
}
+ // Validate required fields
+ if err := ValidateRequired("name", issuer.Name); err != nil {
+ ErrorWithRequestID(w, http.StatusBadRequest, err.Error(), requestID)
+ return
+ }
+ if err := ValidateStringLength("name", issuer.Name, 255); err != nil {
+ ErrorWithRequestID(w, http.StatusBadRequest, err.Error(), requestID)
+ return
+ }
+ if issuer.Type == "" {
+ ErrorWithRequestID(w, http.StatusBadRequest, "type is required", requestID)
+ return
+ }
+
created, err := h.svc.CreateIssuer(issuer)
if err != nil {
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to create issuer", requestID)
diff --git a/internal/api/handler/owners.go b/internal/api/handler/owners.go
index aaa7abf..7e9b348 100644
--- a/internal/api/handler/owners.go
+++ b/internal/api/handler/owners.go
@@ -112,6 +112,16 @@ func (h OwnerHandler) CreateOwner(w http.ResponseWriter, r *http.Request) {
return
}
+ // Validate required fields
+ if err := ValidateRequired("name", owner.Name); err != nil {
+ ErrorWithRequestID(w, http.StatusBadRequest, err.Error(), requestID)
+ return
+ }
+ if err := ValidateStringLength("name", owner.Name, 255); err != nil {
+ ErrorWithRequestID(w, http.StatusBadRequest, err.Error(), requestID)
+ return
+ }
+
created, err := h.svc.CreateOwner(owner)
if err != nil {
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to create owner", requestID)
diff --git a/internal/api/handler/policies.go b/internal/api/handler/policies.go
index f58877c..9edb026 100644
--- a/internal/api/handler/policies.go
+++ b/internal/api/handler/policies.go
@@ -113,6 +113,20 @@ func (h PolicyHandler) CreatePolicy(w http.ResponseWriter, r *http.Request) {
return
}
+ // Validate required fields
+ if err := ValidateRequired("name", policy.Name); err != nil {
+ ErrorWithRequestID(w, http.StatusBadRequest, err.Error(), requestID)
+ return
+ }
+ if policy.Type == "" {
+ ErrorWithRequestID(w, http.StatusBadRequest, "type is required", requestID)
+ return
+ }
+ if err := ValidatePolicyType(policy.Type); err != nil {
+ ErrorWithRequestID(w, http.StatusBadRequest, err.Error(), requestID)
+ return
+ }
+
created, err := h.svc.CreatePolicy(policy)
if err != nil {
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to create policy", requestID)
@@ -146,6 +160,20 @@ func (h PolicyHandler) UpdatePolicy(w http.ResponseWriter, r *http.Request) {
return
}
+ // Validate fields if provided
+ if policy.Name != "" {
+ if err := ValidateStringLength("name", policy.Name, 255); err != nil {
+ ErrorWithRequestID(w, http.StatusBadRequest, err.Error(), requestID)
+ return
+ }
+ }
+ if policy.Type != "" {
+ if err := ValidatePolicyType(policy.Type); err != nil {
+ ErrorWithRequestID(w, http.StatusBadRequest, err.Error(), requestID)
+ return
+ }
+ }
+
updated, err := h.svc.UpdatePolicy(id, policy)
if err != nil {
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to update policy", requestID)
diff --git a/internal/api/handler/targets.go b/internal/api/handler/targets.go
index b7f1623..1eda98e 100644
--- a/internal/api/handler/targets.go
+++ b/internal/api/handler/targets.go
@@ -110,6 +110,20 @@ func (h TargetHandler) CreateTarget(w http.ResponseWriter, r *http.Request) {
return
}
+ // Validate required fields
+ if err := ValidateRequired("name", target.Name); err != nil {
+ ErrorWithRequestID(w, http.StatusBadRequest, err.Error(), requestID)
+ return
+ }
+ if err := ValidateStringLength("name", target.Name, 255); err != nil {
+ ErrorWithRequestID(w, http.StatusBadRequest, err.Error(), requestID)
+ return
+ }
+ if target.Type == "" {
+ ErrorWithRequestID(w, http.StatusBadRequest, "type is required", requestID)
+ return
+ }
+
created, err := h.svc.CreateTarget(target)
if err != nil {
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to create target", requestID)
diff --git a/internal/api/handler/teams.go b/internal/api/handler/teams.go
index 47b9a46..4eed9d7 100644
--- a/internal/api/handler/teams.go
+++ b/internal/api/handler/teams.go
@@ -112,6 +112,16 @@ func (h TeamHandler) CreateTeam(w http.ResponseWriter, r *http.Request) {
return
}
+ // Validate required fields
+ if err := ValidateRequired("name", team.Name); err != nil {
+ ErrorWithRequestID(w, http.StatusBadRequest, err.Error(), requestID)
+ return
+ }
+ if err := ValidateStringLength("name", team.Name, 255); err != nil {
+ ErrorWithRequestID(w, http.StatusBadRequest, err.Error(), requestID)
+ return
+ }
+
created, err := h.svc.CreateTeam(team)
if err != nil {
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to create team", requestID)
diff --git a/internal/api/handler/validation.go b/internal/api/handler/validation.go
new file mode 100644
index 0000000..5c1364f
--- /dev/null
+++ b/internal/api/handler/validation.go
@@ -0,0 +1,135 @@
+package handler
+
+import (
+ "fmt"
+ "net"
+ "strings"
+)
+
+// ValidationError represents a validation error with field-level details.
+type ValidationError struct {
+ Field string
+ Message string
+}
+
+// ValidateCommonName validates a certificate common name.
+func ValidateCommonName(cn string) error {
+ if cn == "" {
+ return ValidationError{Field: "common_name", Message: "common_name is required"}
+ }
+ if len(cn) > 253 {
+ return ValidationError{Field: "common_name", Message: "common_name must be 253 characters or fewer"}
+ }
+ // Basic hostname validation: allow alphanumeric, dots, hyphens
+ if err := isValidHostname(cn); err != nil {
+ return ValidationError{Field: "common_name", Message: fmt.Sprintf("invalid hostname format: %v", err)}
+ }
+ return nil
+}
+
+// ValidateRequired checks if a string field is present and non-empty.
+func ValidateRequired(field, value string) error {
+ if value == "" {
+ return ValidationError{Field: field, Message: fmt.Sprintf("%s is required", field)}
+ }
+ return nil
+}
+
+// ValidateStringLength checks if a string is within acceptable length bounds.
+func ValidateStringLength(field, value string, maxLen int) error {
+ if len(value) > maxLen {
+ return ValidationError{Field: field, Message: fmt.Sprintf("%s must be %d characters or fewer", field, maxLen)}
+ }
+ return nil
+}
+
+// ValidateCSRPEM validates a certificate signing request PEM block.
+func ValidateCSRPEM(csrPEM string) error {
+ if csrPEM == "" {
+ return ValidationError{Field: "csr_pem", Message: "csr_pem is required"}
+ }
+ if !strings.HasPrefix(strings.TrimSpace(csrPEM), "-----BEGIN CERTIFICATE REQUEST-----") {
+ return ValidationError{Field: "csr_pem", Message: "csr_pem must be a valid PEM-encoded certificate request"}
+ }
+ return nil
+}
+
+// ValidatePolicyType checks if a policy rule type is valid.
+func ValidatePolicyType(policyType interface{}) error {
+ validTypes := map[string]bool{
+ "AllowedIssuers": true,
+ "AllowedDomains": true,
+ "RequiredMetadata": true,
+ "AllowedEnvironments": true,
+ "RenewalLeadTime": true,
+ }
+ typeStr := fmt.Sprintf("%v", policyType)
+ if !validTypes[typeStr] {
+ return ValidationError{Field: "type", Message: "type must be one of: AllowedIssuers, AllowedDomains, RequiredMetadata, AllowedEnvironments, RenewalLeadTime"}
+ }
+ return nil
+}
+
+// ValidatePolicySeverity checks if a severity level is valid.
+func ValidatePolicySeverity(severity interface{}) error {
+ validSeverities := map[string]bool{
+ "Warning": true,
+ "Error": true,
+ "Critical": true,
+ }
+ sevStr := fmt.Sprintf("%v", severity)
+ if !validSeverities[sevStr] {
+ return ValidationError{Field: "severity", Message: "severity must be one of: Warning, Error, Critical"}
+ }
+ return nil
+}
+
+// isValidHostname performs basic validation on a hostname.
+func isValidHostname(hostname string) error {
+ // Use net.SplitHostPort-compatible check
+ // Hostname can be an IP or domain name
+ if ip := net.ParseIP(hostname); ip != nil {
+ return nil // Valid IP address
+ }
+
+ // For domain names, check basic format
+ if len(hostname) == 0 || len(hostname) > 253 {
+ return fmt.Errorf("hostname length invalid")
+ }
+
+ // Check for invalid characters (very basic)
+ for _, char := range hostname {
+ if !isValidHostnameChar(char) {
+ return fmt.Errorf("hostname contains invalid character: %c", char)
+ }
+ }
+
+ // Labels must not start or end with hyphen
+ labels := strings.Split(hostname, ".")
+ for _, label := range labels {
+ if len(label) == 0 {
+ return fmt.Errorf("hostname has empty label")
+ }
+ if strings.HasPrefix(label, "-") || strings.HasSuffix(label, "-") {
+ return fmt.Errorf("hostname labels cannot start or end with hyphen")
+ }
+ }
+
+ return nil
+}
+
+// isValidHostnameChar checks if a character is valid in a hostname.
+func isValidHostnameChar(r rune) bool {
+ return (r >= 'a' && r <= 'z') ||
+ (r >= 'A' && r <= 'Z') ||
+ (r >= '0' && r <= '9') ||
+ r == '.' ||
+ r == '-' ||
+ r == '_' || // Underscores are sometimes allowed
+ r == '*' // Wildcard support
+}
+
+// Error method makes ValidationError satisfy the error interface.
+func (e ValidationError) Error() string {
+ return e.Message
+}
diff --git a/internal/connector/target/nginx/nginx.go b/internal/connector/target/nginx/nginx.go
index 7f7799d..3feab99 100644
--- a/internal/connector/target/nginx/nginx.go
+++ b/internal/connector/target/nginx/nginx.go
@@ -102,7 +102,7 @@ func (c *Connector) DeployCertificate(ctx context.Context, request target.Deploy
TargetAddress: c.config.CertPath,
Message: errMsg,
DeployedAt: time.Now(),
- }, fmt.Errorf(errMsg)
+ }, fmt.Errorf("%s", errMsg)
}
// Write chain with same permissions
@@ -114,7 +114,7 @@ func (c *Connector) DeployCertificate(ctx context.Context, request target.Deploy
TargetAddress: c.config.ChainPath,
Message: errMsg,
DeployedAt: time.Now(),
- }, fmt.Errorf(errMsg)
+ }, fmt.Errorf("%s", errMsg)
}
// Validate NGINX configuration before reload
@@ -128,7 +128,7 @@ func (c *Connector) DeployCertificate(ctx context.Context, request target.Deploy
TargetAddress: c.config.CertPath,
Message: errMsg,
DeployedAt: time.Now(),
- }, fmt.Errorf(errMsg)
+ }, fmt.Errorf("%s", errMsg)
}
// Reload NGINX
@@ -142,7 +142,7 @@ func (c *Connector) DeployCertificate(ctx context.Context, request target.Deploy
TargetAddress: c.config.CertPath,
Message: errMsg,
DeployedAt: time.Now(),
- }, fmt.Errorf(errMsg)
+ }, fmt.Errorf("%s", errMsg)
}
deploymentDuration := time.Since(startTime)
@@ -188,7 +188,7 @@ func (c *Connector) ValidateDeployment(ctx context.Context, request target.Valid
TargetAddress: c.config.CertPath,
Message: errMsg,
ValidatedAt: time.Now(),
- }, fmt.Errorf(errMsg)
+ }, fmt.Errorf("%s", errMsg)
}
// Verify certificate file exists and is readable
@@ -201,7 +201,7 @@ func (c *Connector) ValidateDeployment(ctx context.Context, request target.Valid
TargetAddress: c.config.CertPath,
Message: errMsg,
ValidatedAt: time.Now(),
- }, fmt.Errorf(errMsg)
+ }, fmt.Errorf("%s", errMsg)
}
validationDuration := time.Since(startTime)
diff --git a/web/index.html b/web/index.html
index 7effd51..7a97972 100644
--- a/web/index.html
+++ b/web/index.html
@@ -1,1868 +1,12 @@
-
+
-
-
- certctl - Certificate Control Plane
-
-
-
-
-
+
+
+ certctl - Certificate Control Plane
-
-
-
-
+
+
+
diff --git a/web/index.html.legacy b/web/index.html.legacy
new file mode 100644
index 0000000..7effd51
--- /dev/null
+++ b/web/index.html.legacy
@@ -0,0 +1,1868 @@
+
+
+
+
+
+ certctl - Certificate Control Plane
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/web/package-lock.json b/web/package-lock.json
new file mode 100644
index 0000000..d159b51
--- /dev/null
+++ b/web/package-lock.json
@@ -0,0 +1,2147 @@
+{
+ "name": "web",
+ "version": "1.0.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "web",
+ "version": "1.0.0",
+ "license": "ISC",
+ "dependencies": {
+ "@tanstack/react-query": "^5.90.21",
+ "react": "^18.3.1",
+ "react-dom": "^18.3.1",
+ "react-router-dom": "^6.30.3"
+ },
+ "devDependencies": {
+ "@types/react": "^19.2.14",
+ "@types/react-dom": "^19.2.3",
+ "@vitejs/plugin-react": "^6.0.1",
+ "autoprefixer": "^10.4.27",
+ "postcss": "^8.5.8",
+ "tailwindcss": "^3.4.19",
+ "typescript": "^5.9.3",
+ "vite": "^8.0.0"
+ }
+ },
+ "node_modules/@alloc/quick-lru": {
+ "version": "5.2.0",
+ "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz",
+ "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/@emnapi/core": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.0.tgz",
+ "integrity": "sha512-0DQ98G9ZQZOxfUcQn1waV2yS8aWdZ6kJMbYCJB3oUBecjWYO1fqJ+a1DRfPF3O5JEkwqwP1A9QEN/9mYm2Yd0w==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "@emnapi/wasi-threads": "1.2.0",
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@emnapi/runtime": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.0.tgz",
+ "integrity": "sha512-QN75eB0IH2ywSpRpNddCRfQIhmJYBCJ1x5Lb3IscKAL8bMnVAKnRg8dCoXbHzVLLH7P38N2Z3mtulB7W0J0FKw==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@emnapi/wasi-threads": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.0.tgz",
+ "integrity": "sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@jridgewell/gen-mapping": {
+ "version": "0.3.13",
+ "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
+ "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.5.0",
+ "@jridgewell/trace-mapping": "^0.3.24"
+ }
+ },
+ "node_modules/@jridgewell/resolve-uri": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
+ "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@jridgewell/sourcemap-codec": {
+ "version": "1.5.5",
+ "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
+ "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@jridgewell/trace-mapping": {
+ "version": "0.3.31",
+ "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
+ "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/resolve-uri": "^3.1.0",
+ "@jridgewell/sourcemap-codec": "^1.4.14"
+ }
+ },
+ "node_modules/@napi-rs/wasm-runtime": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.1.tgz",
+ "integrity": "sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "@emnapi/core": "^1.7.1",
+ "@emnapi/runtime": "^1.7.1",
+ "@tybys/wasm-util": "^0.10.1"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/Brooooooklyn"
+ }
+ },
+ "node_modules/@nodelib/fs.scandir": {
+ "version": "2.1.5",
+ "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
+ "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@nodelib/fs.stat": "2.0.5",
+ "run-parallel": "^1.1.9"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/@nodelib/fs.stat": {
+ "version": "2.0.5",
+ "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz",
+ "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/@nodelib/fs.walk": {
+ "version": "1.2.8",
+ "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz",
+ "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@nodelib/fs.scandir": "2.1.5",
+ "fastq": "^1.6.0"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/@oxc-project/runtime": {
+ "version": "0.115.0",
+ "resolved": "https://registry.npmjs.org/@oxc-project/runtime/-/runtime-0.115.0.tgz",
+ "integrity": "sha512-Rg8Wlt5dCbXhQnsXPrkOjL1DTSvXLgb2R/KYfnf1/K+R0k6UMLEmbQXPM+kwrWqSmWA2t0B1EtHy2/3zikQpvQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxc-project/types": {
+ "version": "0.115.0",
+ "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.115.0.tgz",
+ "integrity": "sha512-4n91DKnebUS4yjUHl2g3/b2T+IUdCfmoZGhmwsovZCDaJSs+QkVAM+0AqqTxHSsHfeiMuueT75cZaZcT/m0pSw==",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/Boshen"
+ }
+ },
+ "node_modules/@remix-run/router": {
+ "version": "1.23.2",
+ "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.2.tgz",
+ "integrity": "sha512-Ic6m2U/rMjTkhERIa/0ZtXJP17QUi2CbWE7cqx4J58M8aA3QTfW+2UlQ4psvTX9IO1RfNVhK3pcpdjej7L+t2w==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/@rolldown/binding-android-arm64": {
+ "version": "1.0.0-rc.9",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.9.tgz",
+ "integrity": "sha512-lcJL0bN5hpgJfSIz/8PIf02irmyL43P+j1pTCfbD1DbLkmGRuFIA4DD3B3ZOvGqG0XiVvRznbKtN0COQVaKUTg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-darwin-arm64": {
+ "version": "1.0.0-rc.9",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.9.tgz",
+ "integrity": "sha512-J7Zk3kLYFsLtuH6U+F4pS2sYVzac0qkjcO5QxHS7OS7yZu2LRs+IXo+uvJ/mvpyUljDJ3LROZPoQfgBIpCMhdQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-darwin-x64": {
+ "version": "1.0.0-rc.9",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.9.tgz",
+ "integrity": "sha512-iwtmmghy8nhfRGeNAIltcNXzD0QMNaaA5U/NyZc1Ia4bxrzFByNMDoppoC+hl7cDiUq5/1CnFthpT9n+UtfFyg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-freebsd-x64": {
+ "version": "1.0.0-rc.9",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.9.tgz",
+ "integrity": "sha512-DLFYI78SCiZr5VvdEplsVC2Vx53lnA4/Ga5C65iyldMVaErr86aiqCoNBLl92PXPfDtUYjUh+xFFor40ueNs4Q==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-arm-gnueabihf": {
+ "version": "1.0.0-rc.9",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.9.tgz",
+ "integrity": "sha512-CsjTmTwd0Hri6iTw/DRMK7kOZ7FwAkrO4h8YWKoX/kcj833e4coqo2wzIFywtch/8Eb5enQ/lwLM7w6JX1W5RQ==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-arm64-gnu": {
+ "version": "1.0.0-rc.9",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.9.tgz",
+ "integrity": "sha512-2x9O2JbSPxpxMDhP9Z74mahAStibTlrBMW0520+epJH5sac7/LwZW5Bmg/E6CXuEF53JJFW509uP+lSedaUNxg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-arm64-musl": {
+ "version": "1.0.0-rc.9",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.9.tgz",
+ "integrity": "sha512-JA1QRW31ogheAIRhIg9tjMfsYbglXXYGNPLdPEYrwFxdbkQCAzvpSCSHCDWNl4hTtrol8WeboCSEpjdZK8qrCg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-ppc64-gnu": {
+ "version": "1.0.0-rc.9",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.9.tgz",
+ "integrity": "sha512-aOKU9dJheda8Kj8Y3w9gnt9QFOO+qKPAl8SWd7JPHP+Cu0EuDAE5wokQubLzIDQWg2myXq2XhTpOVS07qqvT+w==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-s390x-gnu": {
+ "version": "1.0.0-rc.9",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.9.tgz",
+ "integrity": "sha512-OalO94fqj7IWRn3VdXWty75jC5dk4C197AWEuMhIpvVv2lw9fiPhud0+bW2ctCxb3YoBZor71QHbY+9/WToadA==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-x64-gnu": {
+ "version": "1.0.0-rc.9",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.9.tgz",
+ "integrity": "sha512-cVEl1vZtBsBZna3YMjGXNvnYYrOJ7RzuWvZU0ffvJUexWkukMaDuGhUXn0rjnV0ptzGVkvc+vW9Yqy6h8YX4pg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-x64-musl": {
+ "version": "1.0.0-rc.9",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.9.tgz",
+ "integrity": "sha512-UzYnKCIIc4heAKgI4PZ3dfBGUZefGCJ1TPDuLHoCzgrMYPb5Rv6TLFuYtyM4rWyHM7hymNdsg5ik2C+UD9VDbA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-openharmony-arm64": {
+ "version": "1.0.0-rc.9",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.9.tgz",
+ "integrity": "sha512-+6zoiF+RRyf5cdlFQP7nm58mq7+/2PFaY2DNQeD4B87N36JzfF/l9mdBkkmTvSYcYPE8tMh/o3cRlsx1ldLfog==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-wasm32-wasi": {
+ "version": "1.0.0-rc.9",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.9.tgz",
+ "integrity": "sha512-rgFN6sA/dyebil3YTlL2evvi/M+ivhfnyxec7AccTpRPccno/rPoNlqybEZQBkcbZu8Hy+eqNJCqfBR8P7Pg8g==",
+ "cpu": [
+ "wasm32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "@napi-rs/wasm-runtime": "^1.1.1"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/@rolldown/binding-win32-arm64-msvc": {
+ "version": "1.0.0-rc.9",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.9.tgz",
+ "integrity": "sha512-lHVNUG/8nlF1IQk1C0Ci574qKYyty2goMiPlRqkC5R+3LkXDkL5Dhx8ytbxq35m+pkHVIvIxviD+TWLdfeuadA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-win32-x64-msvc": {
+ "version": "1.0.0-rc.9",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.9.tgz",
+ "integrity": "sha512-G0oA4+w1iY5AGi5HcDTxWsoxF509hrFIPB2rduV5aDqS9FtDg1CAfa7V34qImbjfhIcA8C+RekocJZA96EarwQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/pluginutils": {
+ "version": "1.0.0-rc.7",
+ "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.7.tgz",
+ "integrity": "sha512-qujRfC8sFVInYSPPMLQByRh7zhwkGFS4+tyMQ83srV1qrxL4g8E2tyxVVyxd0+8QeBM1mIk9KbWxkegRr76XzA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@tanstack/query-core": {
+ "version": "5.90.20",
+ "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.90.20.tgz",
+ "integrity": "sha512-OMD2HLpNouXEfZJWcKeVKUgQ5n+n3A2JFmBaScpNDUqSrQSjiveC7dKMe53uJUg1nDG16ttFPz2xfilz6i2uVg==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/tannerlinsley"
+ }
+ },
+ "node_modules/@tanstack/react-query": {
+ "version": "5.90.21",
+ "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.21.tgz",
+ "integrity": "sha512-0Lu6y5t+tvlTJMTO7oh5NSpJfpg/5D41LlThfepTixPYkJ0sE2Jj0m0f6yYqujBwIXlId87e234+MxG3D3g7kg==",
+ "license": "MIT",
+ "dependencies": {
+ "@tanstack/query-core": "5.90.20"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/tannerlinsley"
+ },
+ "peerDependencies": {
+ "react": "^18 || ^19"
+ }
+ },
+ "node_modules/@tybys/wasm-util": {
+ "version": "0.10.1",
+ "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz",
+ "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@types/react": {
+ "version": "19.2.14",
+ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
+ "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "csstype": "^3.2.2"
+ }
+ },
+ "node_modules/@types/react-dom": {
+ "version": "19.2.3",
+ "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz",
+ "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
+ "dev": true,
+ "license": "MIT",
+ "peerDependencies": {
+ "@types/react": "^19.2.0"
+ }
+ },
+ "node_modules/@vitejs/plugin-react": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.1.tgz",
+ "integrity": "sha512-l9X/E3cDb+xY3SWzlG1MOGt2usfEHGMNIaegaUGFsLkb3RCn/k8/TOXBcab+OndDI4TBtktT8/9BwwW8Vi9KUQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@rolldown/pluginutils": "1.0.0-rc.7"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ },
+ "peerDependencies": {
+ "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0",
+ "babel-plugin-react-compiler": "^1.0.0",
+ "vite": "^8.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@rolldown/plugin-babel": {
+ "optional": true
+ },
+ "babel-plugin-react-compiler": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/any-promise": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz",
+ "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/anymatch": {
+ "version": "3.1.3",
+ "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz",
+ "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "normalize-path": "^3.0.0",
+ "picomatch": "^2.0.4"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/arg": {
+ "version": "5.0.2",
+ "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz",
+ "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/autoprefixer": {
+ "version": "10.4.27",
+ "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.27.tgz",
+ "integrity": "sha512-NP9APE+tO+LuJGn7/9+cohklunJsXWiaWEfV3si4Gi/XHDwVNgkwr1J3RQYFIvPy76GmJ9/bW8vyoU1LcxwKHA==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/autoprefixer"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "browserslist": "^4.28.1",
+ "caniuse-lite": "^1.0.30001774",
+ "fraction.js": "^5.3.4",
+ "picocolors": "^1.1.1",
+ "postcss-value-parser": "^4.2.0"
+ },
+ "bin": {
+ "autoprefixer": "bin/autoprefixer"
+ },
+ "engines": {
+ "node": "^10 || ^12 || >=14"
+ },
+ "peerDependencies": {
+ "postcss": "^8.1.0"
+ }
+ },
+ "node_modules/baseline-browser-mapping": {
+ "version": "2.10.8",
+ "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.8.tgz",
+ "integrity": "sha512-PCLz/LXGBsNTErbtB6i5u4eLpHeMfi93aUv5duMmj6caNu6IphS4q6UevDnL36sZQv9lrP11dbPKGMaXPwMKfQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "baseline-browser-mapping": "dist/cli.cjs"
+ },
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/binary-extensions": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
+ "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/braces": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
+ "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "fill-range": "^7.1.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/browserslist": {
+ "version": "4.28.1",
+ "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz",
+ "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/browserslist"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/browserslist"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "baseline-browser-mapping": "^2.9.0",
+ "caniuse-lite": "^1.0.30001759",
+ "electron-to-chromium": "^1.5.263",
+ "node-releases": "^2.0.27",
+ "update-browserslist-db": "^1.2.0"
+ },
+ "bin": {
+ "browserslist": "cli.js"
+ },
+ "engines": {
+ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
+ }
+ },
+ "node_modules/camelcase-css": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz",
+ "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/caniuse-lite": {
+ "version": "1.0.30001779",
+ "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001779.tgz",
+ "integrity": "sha512-U5og2PN7V4DMgF50YPNtnZJGWVLFjjsN3zb6uMT5VGYIewieDj1upwfuVNXf4Kor+89c3iCRJnSzMD5LmTvsfA==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/browserslist"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/caniuse-lite"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "CC-BY-4.0"
+ },
+ "node_modules/chokidar": {
+ "version": "3.6.0",
+ "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
+ "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "anymatch": "~3.1.2",
+ "braces": "~3.0.2",
+ "glob-parent": "~5.1.2",
+ "is-binary-path": "~2.1.0",
+ "is-glob": "~4.0.1",
+ "normalize-path": "~3.0.0",
+ "readdirp": "~3.6.0"
+ },
+ "engines": {
+ "node": ">= 8.10.0"
+ },
+ "funding": {
+ "url": "https://paulmillr.com/funding/"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.2"
+ }
+ },
+ "node_modules/chokidar/node_modules/glob-parent": {
+ "version": "5.1.2",
+ "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
+ "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "is-glob": "^4.0.1"
+ },
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/commander": {
+ "version": "4.1.1",
+ "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz",
+ "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/cssesc": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
+ "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "cssesc": "bin/cssesc"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/csstype": {
+ "version": "3.2.3",
+ "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
+ "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/detect-libc": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
+ "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/didyoumean": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz",
+ "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==",
+ "dev": true,
+ "license": "Apache-2.0"
+ },
+ "node_modules/dlv": {
+ "version": "1.1.3",
+ "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz",
+ "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/electron-to-chromium": {
+ "version": "1.5.313",
+ "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.313.tgz",
+ "integrity": "sha512-QBMrTWEf00GXZmJyx2lbYD45jpI3TUFnNIzJ5BBc8piGUDwMPa1GV6HJWTZVvY/eiN3fSopl7NRbgGp9sZ9LTA==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/escalade": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
+ "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/fast-glob": {
+ "version": "3.3.3",
+ "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz",
+ "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@nodelib/fs.stat": "^2.0.2",
+ "@nodelib/fs.walk": "^1.2.3",
+ "glob-parent": "^5.1.2",
+ "merge2": "^1.3.0",
+ "micromatch": "^4.0.8"
+ },
+ "engines": {
+ "node": ">=8.6.0"
+ }
+ },
+ "node_modules/fast-glob/node_modules/glob-parent": {
+ "version": "5.1.2",
+ "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
+ "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "is-glob": "^4.0.1"
+ },
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/fastq": {
+ "version": "1.20.1",
+ "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz",
+ "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "reusify": "^1.0.4"
+ }
+ },
+ "node_modules/fill-range": {
+ "version": "7.1.1",
+ "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
+ "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "to-regex-range": "^5.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/fraction.js": {
+ "version": "5.3.4",
+ "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz",
+ "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "*"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/rawify"
+ }
+ },
+ "node_modules/fsevents": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
+ "node_modules/function-bind": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
+ "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/glob-parent": {
+ "version": "6.0.2",
+ "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
+ "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "is-glob": "^4.0.3"
+ },
+ "engines": {
+ "node": ">=10.13.0"
+ }
+ },
+ "node_modules/hasown": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
+ "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "function-bind": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/is-binary-path": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
+ "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "binary-extensions": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/is-core-module": {
+ "version": "2.16.1",
+ "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz",
+ "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "hasown": "^2.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-extglob": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
+ "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/is-glob": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
+ "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "is-extglob": "^2.1.1"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/is-number": {
+ "version": "7.0.0",
+ "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
+ "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.12.0"
+ }
+ },
+ "node_modules/jiti": {
+ "version": "1.21.7",
+ "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz",
+ "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "jiti": "bin/jiti.js"
+ }
+ },
+ "node_modules/js-tokens": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
+ "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
+ "license": "MIT"
+ },
+ "node_modules/lightningcss": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
+ "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==",
+ "dev": true,
+ "license": "MPL-2.0",
+ "dependencies": {
+ "detect-libc": "^2.0.3"
+ },
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ },
+ "optionalDependencies": {
+ "lightningcss-android-arm64": "1.32.0",
+ "lightningcss-darwin-arm64": "1.32.0",
+ "lightningcss-darwin-x64": "1.32.0",
+ "lightningcss-freebsd-x64": "1.32.0",
+ "lightningcss-linux-arm-gnueabihf": "1.32.0",
+ "lightningcss-linux-arm64-gnu": "1.32.0",
+ "lightningcss-linux-arm64-musl": "1.32.0",
+ "lightningcss-linux-x64-gnu": "1.32.0",
+ "lightningcss-linux-x64-musl": "1.32.0",
+ "lightningcss-win32-arm64-msvc": "1.32.0",
+ "lightningcss-win32-x64-msvc": "1.32.0"
+ }
+ },
+ "node_modules/lightningcss-android-arm64": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz",
+ "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-darwin-arm64": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz",
+ "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-darwin-x64": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz",
+ "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-freebsd-x64": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz",
+ "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-arm-gnueabihf": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz",
+ "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-arm64-gnu": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz",
+ "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-arm64-musl": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz",
+ "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-x64-gnu": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz",
+ "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-x64-musl": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz",
+ "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-win32-arm64-msvc": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz",
+ "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-win32-x64-msvc": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz",
+ "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lilconfig": {
+ "version": "3.1.3",
+ "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz",
+ "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=14"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/antonk52"
+ }
+ },
+ "node_modules/lines-and-columns": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz",
+ "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/loose-envify": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
+ "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
+ "license": "MIT",
+ "dependencies": {
+ "js-tokens": "^3.0.0 || ^4.0.0"
+ },
+ "bin": {
+ "loose-envify": "cli.js"
+ }
+ },
+ "node_modules/merge2": {
+ "version": "1.4.1",
+ "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
+ "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/micromatch": {
+ "version": "4.0.8",
+ "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
+ "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "braces": "^3.0.3",
+ "picomatch": "^2.3.1"
+ },
+ "engines": {
+ "node": ">=8.6"
+ }
+ },
+ "node_modules/mz": {
+ "version": "2.7.0",
+ "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz",
+ "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "any-promise": "^1.0.0",
+ "object-assign": "^4.0.1",
+ "thenify-all": "^1.0.0"
+ }
+ },
+ "node_modules/nanoid": {
+ "version": "3.3.11",
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
+ "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "bin": {
+ "nanoid": "bin/nanoid.cjs"
+ },
+ "engines": {
+ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
+ }
+ },
+ "node_modules/node-releases": {
+ "version": "2.0.36",
+ "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.36.tgz",
+ "integrity": "sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/normalize-path": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
+ "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/object-assign": {
+ "version": "4.1.1",
+ "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
+ "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/object-hash": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz",
+ "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/path-parse": {
+ "version": "1.0.7",
+ "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz",
+ "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/picocolors": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
+ "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/picomatch": {
+ "version": "2.3.1",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
+ "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8.6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/pify": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz",
+ "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/pirates": {
+ "version": "4.0.7",
+ "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz",
+ "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/postcss": {
+ "version": "8.5.8",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz",
+ "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/postcss"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "nanoid": "^3.3.11",
+ "picocolors": "^1.1.1",
+ "source-map-js": "^1.2.1"
+ },
+ "engines": {
+ "node": "^10 || ^12 || >=14"
+ }
+ },
+ "node_modules/postcss-import": {
+ "version": "15.1.0",
+ "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz",
+ "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "postcss-value-parser": "^4.0.0",
+ "read-cache": "^1.0.0",
+ "resolve": "^1.1.7"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ },
+ "peerDependencies": {
+ "postcss": "^8.0.0"
+ }
+ },
+ "node_modules/postcss-js": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.1.0.tgz",
+ "integrity": "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "camelcase-css": "^2.0.1"
+ },
+ "engines": {
+ "node": "^12 || ^14 || >= 16"
+ },
+ "peerDependencies": {
+ "postcss": "^8.4.21"
+ }
+ },
+ "node_modules/postcss-load-config": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz",
+ "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "lilconfig": "^3.1.1"
+ },
+ "engines": {
+ "node": ">= 18"
+ },
+ "peerDependencies": {
+ "jiti": ">=1.21.0",
+ "postcss": ">=8.0.9",
+ "tsx": "^4.8.1",
+ "yaml": "^2.4.2"
+ },
+ "peerDependenciesMeta": {
+ "jiti": {
+ "optional": true
+ },
+ "postcss": {
+ "optional": true
+ },
+ "tsx": {
+ "optional": true
+ },
+ "yaml": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/postcss-nested": {
+ "version": "6.2.0",
+ "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz",
+ "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "postcss-selector-parser": "^6.1.1"
+ },
+ "engines": {
+ "node": ">=12.0"
+ },
+ "peerDependencies": {
+ "postcss": "^8.2.14"
+ }
+ },
+ "node_modules/postcss-selector-parser": {
+ "version": "6.1.2",
+ "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz",
+ "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "cssesc": "^3.0.0",
+ "util-deprecate": "^1.0.2"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/postcss-value-parser": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz",
+ "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/queue-microtask": {
+ "version": "1.2.3",
+ "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
+ "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT"
+ },
+ "node_modules/react": {
+ "version": "18.3.1",
+ "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
+ "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
+ "license": "MIT",
+ "dependencies": {
+ "loose-envify": "^1.1.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/react-dom": {
+ "version": "18.3.1",
+ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
+ "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
+ "license": "MIT",
+ "dependencies": {
+ "loose-envify": "^1.1.0",
+ "scheduler": "^0.23.2"
+ },
+ "peerDependencies": {
+ "react": "^18.3.1"
+ }
+ },
+ "node_modules/react-router": {
+ "version": "6.30.3",
+ "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.3.tgz",
+ "integrity": "sha512-XRnlbKMTmktBkjCLE8/XcZFlnHvr2Ltdr1eJX4idL55/9BbORzyZEaIkBFDhFGCEWBBItsVrDxwx3gnisMitdw==",
+ "license": "MIT",
+ "dependencies": {
+ "@remix-run/router": "1.23.2"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ },
+ "peerDependencies": {
+ "react": ">=16.8"
+ }
+ },
+ "node_modules/react-router-dom": {
+ "version": "6.30.3",
+ "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.3.tgz",
+ "integrity": "sha512-pxPcv1AczD4vso7G4Z3TKcvlxK7g7TNt3/FNGMhfqyntocvYKj+GCatfigGDjbLozC4baguJ0ReCigoDJXb0ag==",
+ "license": "MIT",
+ "dependencies": {
+ "@remix-run/router": "1.23.2",
+ "react-router": "6.30.3"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ },
+ "peerDependencies": {
+ "react": ">=16.8",
+ "react-dom": ">=16.8"
+ }
+ },
+ "node_modules/read-cache": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",
+ "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "pify": "^2.3.0"
+ }
+ },
+ "node_modules/readdirp": {
+ "version": "3.6.0",
+ "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
+ "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "picomatch": "^2.2.1"
+ },
+ "engines": {
+ "node": ">=8.10.0"
+ }
+ },
+ "node_modules/resolve": {
+ "version": "1.22.11",
+ "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz",
+ "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "is-core-module": "^2.16.1",
+ "path-parse": "^1.0.7",
+ "supports-preserve-symlinks-flag": "^1.0.0"
+ },
+ "bin": {
+ "resolve": "bin/resolve"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/reusify": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz",
+ "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "iojs": ">=1.0.0",
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/rolldown": {
+ "version": "1.0.0-rc.9",
+ "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.9.tgz",
+ "integrity": "sha512-9EbgWge7ZH+yqb4d2EnELAntgPTWbfL8ajiTW+SyhJEC4qhBbkCKbqFV4Ge4zmu5ziQuVbWxb/XwLZ+RIO7E8Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@oxc-project/types": "=0.115.0",
+ "@rolldown/pluginutils": "1.0.0-rc.9"
+ },
+ "bin": {
+ "rolldown": "bin/cli.mjs"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ },
+ "optionalDependencies": {
+ "@rolldown/binding-android-arm64": "1.0.0-rc.9",
+ "@rolldown/binding-darwin-arm64": "1.0.0-rc.9",
+ "@rolldown/binding-darwin-x64": "1.0.0-rc.9",
+ "@rolldown/binding-freebsd-x64": "1.0.0-rc.9",
+ "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.9",
+ "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.9",
+ "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.9",
+ "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.9",
+ "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.9",
+ "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.9",
+ "@rolldown/binding-linux-x64-musl": "1.0.0-rc.9",
+ "@rolldown/binding-openharmony-arm64": "1.0.0-rc.9",
+ "@rolldown/binding-wasm32-wasi": "1.0.0-rc.9",
+ "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.9",
+ "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.9"
+ }
+ },
+ "node_modules/rolldown/node_modules/@rolldown/pluginutils": {
+ "version": "1.0.0-rc.9",
+ "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.9.tgz",
+ "integrity": "sha512-w6oiRWgEBl04QkFZgmW+jnU1EC9b57Oihi2ot3HNWIQRqgHp5PnYDia5iZ5FF7rpa4EQdiqMDXjlqKGXBhsoXw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/run-parallel": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
+ "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "queue-microtask": "^1.2.2"
+ }
+ },
+ "node_modules/scheduler": {
+ "version": "0.23.2",
+ "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
+ "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==",
+ "license": "MIT",
+ "dependencies": {
+ "loose-envify": "^1.1.0"
+ }
+ },
+ "node_modules/source-map-js": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
+ "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/sucrase": {
+ "version": "3.35.1",
+ "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz",
+ "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/gen-mapping": "^0.3.2",
+ "commander": "^4.0.0",
+ "lines-and-columns": "^1.1.6",
+ "mz": "^2.7.0",
+ "pirates": "^4.0.1",
+ "tinyglobby": "^0.2.11",
+ "ts-interface-checker": "^0.1.9"
+ },
+ "bin": {
+ "sucrase": "bin/sucrase",
+ "sucrase-node": "bin/sucrase-node"
+ },
+ "engines": {
+ "node": ">=16 || 14 >=14.17"
+ }
+ },
+ "node_modules/supports-preserve-symlinks-flag": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz",
+ "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/tailwindcss": {
+ "version": "3.4.19",
+ "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz",
+ "integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@alloc/quick-lru": "^5.2.0",
+ "arg": "^5.0.2",
+ "chokidar": "^3.6.0",
+ "didyoumean": "^1.2.2",
+ "dlv": "^1.1.3",
+ "fast-glob": "^3.3.2",
+ "glob-parent": "^6.0.2",
+ "is-glob": "^4.0.3",
+ "jiti": "^1.21.7",
+ "lilconfig": "^3.1.3",
+ "micromatch": "^4.0.8",
+ "normalize-path": "^3.0.0",
+ "object-hash": "^3.0.0",
+ "picocolors": "^1.1.1",
+ "postcss": "^8.4.47",
+ "postcss-import": "^15.1.0",
+ "postcss-js": "^4.0.1",
+ "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0",
+ "postcss-nested": "^6.2.0",
+ "postcss-selector-parser": "^6.1.2",
+ "resolve": "^1.22.8",
+ "sucrase": "^3.35.0"
+ },
+ "bin": {
+ "tailwind": "lib/cli.js",
+ "tailwindcss": "lib/cli.js"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/thenify": {
+ "version": "3.3.1",
+ "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz",
+ "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "any-promise": "^1.0.0"
+ }
+ },
+ "node_modules/thenify-all": {
+ "version": "1.6.0",
+ "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz",
+ "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "thenify": ">= 3.1.0 < 4"
+ },
+ "engines": {
+ "node": ">=0.8"
+ }
+ },
+ "node_modules/tinyglobby": {
+ "version": "0.2.15",
+ "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
+ "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "fdir": "^6.5.0",
+ "picomatch": "^4.0.3"
+ },
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/SuperchupuDev"
+ }
+ },
+ "node_modules/tinyglobby/node_modules/fdir": {
+ "version": "6.5.0",
+ "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
+ "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "peerDependencies": {
+ "picomatch": "^3 || ^4"
+ },
+ "peerDependenciesMeta": {
+ "picomatch": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/tinyglobby/node_modules/picomatch": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
+ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/to-regex-range": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
+ "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "is-number": "^7.0.0"
+ },
+ "engines": {
+ "node": ">=8.0"
+ }
+ },
+ "node_modules/ts-interface-checker": {
+ "version": "0.1.13",
+ "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz",
+ "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==",
+ "dev": true,
+ "license": "Apache-2.0"
+ },
+ "node_modules/tslib": {
+ "version": "2.8.1",
+ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
+ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
+ "dev": true,
+ "license": "0BSD",
+ "optional": true
+ },
+ "node_modules/typescript": {
+ "version": "5.9.3",
+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
+ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "tsc": "bin/tsc",
+ "tsserver": "bin/tsserver"
+ },
+ "engines": {
+ "node": ">=14.17"
+ }
+ },
+ "node_modules/update-browserslist-db": {
+ "version": "1.2.3",
+ "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
+ "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/browserslist"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/browserslist"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "escalade": "^3.2.0",
+ "picocolors": "^1.1.1"
+ },
+ "bin": {
+ "update-browserslist-db": "cli.js"
+ },
+ "peerDependencies": {
+ "browserslist": ">= 4.21.0"
+ }
+ },
+ "node_modules/util-deprecate": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
+ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/vite": {
+ "version": "8.0.0",
+ "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.0.tgz",
+ "integrity": "sha512-fPGaRNj9Zytaf8LEiBhY7Z6ijnFKdzU/+mL8EFBaKr7Vw1/FWcTBAMW0wLPJAGMPX38ZPVCVgLceWiEqeoqL2Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@oxc-project/runtime": "0.115.0",
+ "lightningcss": "^1.32.0",
+ "picomatch": "^4.0.3",
+ "postcss": "^8.5.8",
+ "rolldown": "1.0.0-rc.9",
+ "tinyglobby": "^0.2.15"
+ },
+ "bin": {
+ "vite": "bin/vite.js"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ },
+ "funding": {
+ "url": "https://github.com/vitejs/vite?sponsor=1"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.3"
+ },
+ "peerDependencies": {
+ "@types/node": "^20.19.0 || >=22.12.0",
+ "@vitejs/devtools": "^0.0.0-alpha.31",
+ "esbuild": "^0.27.0",
+ "jiti": ">=1.21.0",
+ "less": "^4.0.0",
+ "sass": "^1.70.0",
+ "sass-embedded": "^1.70.0",
+ "stylus": ">=0.54.8",
+ "sugarss": "^5.0.0",
+ "terser": "^5.16.0",
+ "tsx": "^4.8.1",
+ "yaml": "^2.4.2"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ },
+ "@vitejs/devtools": {
+ "optional": true
+ },
+ "esbuild": {
+ "optional": true
+ },
+ "jiti": {
+ "optional": true
+ },
+ "less": {
+ "optional": true
+ },
+ "sass": {
+ "optional": true
+ },
+ "sass-embedded": {
+ "optional": true
+ },
+ "stylus": {
+ "optional": true
+ },
+ "sugarss": {
+ "optional": true
+ },
+ "terser": {
+ "optional": true
+ },
+ "tsx": {
+ "optional": true
+ },
+ "yaml": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/vite/node_modules/picomatch": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
+ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ }
+ }
+}
diff --git a/web/package.json b/web/package.json
new file mode 100644
index 0000000..fad9d86
--- /dev/null
+++ b/web/package.json
@@ -0,0 +1,27 @@
+{
+ "name": "certctl-dashboard",
+ "version": "1.0.0",
+ "private": true,
+ "type": "module",
+ "scripts": {
+ "dev": "vite",
+ "build": "tsc && vite build",
+ "preview": "vite preview"
+ },
+ "dependencies": {
+ "@tanstack/react-query": "^5.90.21",
+ "react": "^18.3.1",
+ "react-dom": "^18.3.1",
+ "react-router-dom": "^6.30.3"
+ },
+ "devDependencies": {
+ "@types/react": "^19.2.14",
+ "@types/react-dom": "^19.2.3",
+ "@vitejs/plugin-react": "^6.0.1",
+ "autoprefixer": "^10.4.27",
+ "postcss": "^8.5.8",
+ "tailwindcss": "^3.4.19",
+ "typescript": "^5.9.3",
+ "vite": "^8.0.0"
+ }
+}
diff --git a/web/postcss.config.cjs b/web/postcss.config.cjs
new file mode 100644
index 0000000..33ad091
--- /dev/null
+++ b/web/postcss.config.cjs
@@ -0,0 +1,6 @@
+module.exports = {
+ plugins: {
+ tailwindcss: {},
+ autoprefixer: {},
+ },
+}
diff --git a/web/src/api/client.ts b/web/src/api/client.ts
new file mode 100644
index 0000000..4cb218a
--- /dev/null
+++ b/web/src/api/client.ts
@@ -0,0 +1,90 @@
+import type { Certificate, CertificateVersion, Agent, Job, Notification, AuditEvent, PolicyRule, Issuer, Target, PaginatedResponse } from './types';
+
+const BASE = '/api/v1';
+
+async function fetchJSON(url: string, init?: RequestInit): Promise {
+ const res = await fetch(url, {
+ headers: { 'Content-Type': 'application/json', ...init?.headers },
+ ...init,
+ });
+ if (!res.ok) {
+ const body = await res.json().catch(() => ({ message: res.statusText }));
+ throw new Error(body.message || body.error || `HTTP ${res.status}`);
+ }
+ return res.json();
+}
+
+// Certificates
+export const getCertificates = (params: Record = {}) => {
+ const qs = new URLSearchParams({ page: '1', per_page: '50', ...params }).toString();
+ return fetchJSON>(`${BASE}/certificates?${qs}`);
+};
+
+export const getCertificate = (id: string) =>
+ fetchJSON(`${BASE}/certificates/${id}`);
+
+export const getCertificateVersions = (id: string) =>
+ fetchJSON>(`${BASE}/certificates/${id}/versions`);
+
+export const createCertificate = (data: Partial) =>
+ fetchJSON(`${BASE}/certificates`, { method: 'POST', body: JSON.stringify(data) });
+
+export const triggerRenewal = (id: string) =>
+ fetchJSON<{ message: string }>(`${BASE}/certificates/${id}/renew`, { method: 'POST' });
+
+export const triggerDeployment = (id: string, targetId: string) =>
+ fetchJSON<{ message: string }>(`${BASE}/certificates/${id}/deploy`, {
+ method: 'POST',
+ body: JSON.stringify({ target_id: targetId }),
+ });
+
+// Agents
+export const getAgents = (params: Record = {}) => {
+ const qs = new URLSearchParams({ page: '1', per_page: '50', ...params }).toString();
+ return fetchJSON>(`${BASE}/agents?${qs}`);
+};
+
+export const getAgent = (id: string) =>
+ fetchJSON(`${BASE}/agents/${id}`);
+
+// Jobs
+export const getJobs = (params: Record = {}) => {
+ const qs = new URLSearchParams({ page: '1', per_page: '50', ...params }).toString();
+ return fetchJSON>(`${BASE}/jobs?${qs}`);
+};
+
+export const cancelJob = (id: string) =>
+ fetchJSON<{ message: string }>(`${BASE}/jobs/${id}/cancel`, { method: 'POST' });
+
+// Notifications
+export const getNotifications = (params: Record = {}) => {
+ const qs = new URLSearchParams({ page: '1', per_page: '50', ...params }).toString();
+ return fetchJSON>(`${BASE}/notifications?${qs}`);
+};
+
+// Audit
+export const getAuditEvents = (params: Record = {}) => {
+ const qs = new URLSearchParams({ page: '1', per_page: '50', ...params }).toString();
+ return fetchJSON>(`${BASE}/audit?${qs}`);
+};
+
+// Policies
+export const getPolicies = (params: Record = {}) => {
+ const qs = new URLSearchParams({ page: '1', per_page: '50', ...params }).toString();
+ return fetchJSON>(`${BASE}/policies?${qs}`);
+};
+
+// Issuers
+export const getIssuers = (params: Record = {}) => {
+ const qs = new URLSearchParams({ page: '1', per_page: '50', ...params }).toString();
+ return fetchJSON>(`${BASE}/issuers?${qs}`);
+};
+
+// Targets
+export const getTargets = (params: Record = {}) => {
+ const qs = new URLSearchParams({ page: '1', per_page: '50', ...params }).toString();
+ return fetchJSON>(`${BASE}/targets?${qs}`);
+};
+
+// Health
+export const getHealth = () => fetchJSON<{ status: string }>('/health');
diff --git a/web/src/api/types.ts b/web/src/api/types.ts
new file mode 100644
index 0000000..d542adc
--- /dev/null
+++ b/web/src/api/types.ts
@@ -0,0 +1,133 @@
+export interface Certificate {
+ id: string;
+ name: string;
+ common_name: string;
+ sans: string[];
+ status: string;
+ environment: string;
+ issuer_id: string;
+ owner_id: string;
+ team_id: string;
+ renewal_policy_id: string;
+ serial_number: string;
+ fingerprint: string;
+ key_algorithm: string;
+ key_size: number;
+ issued_at: string;
+ expires_at: string;
+ tags: Record;
+ created_at: string;
+ updated_at: string;
+}
+
+export interface CertificateVersion {
+ id: string;
+ certificate_id: string;
+ version: number;
+ serial_number: string;
+ fingerprint: string;
+ cert_pem: string;
+ chain_pem: string;
+ csr_pem: string;
+ not_before: string;
+ not_after: string;
+ created_at: string;
+}
+
+export interface Agent {
+ id: string;
+ name: string;
+ hostname: string;
+ ip_address: string;
+ status: string;
+ version: string;
+ last_heartbeat: string;
+ capabilities: string[];
+ tags: Record;
+ created_at: string;
+ updated_at: string;
+}
+
+export interface Job {
+ id: string;
+ certificate_id: string;
+ type: string;
+ status: string;
+ attempts: number;
+ max_attempts: number;
+ error_message: string;
+ scheduled_at: string;
+ started_at: string;
+ completed_at: string;
+ created_at: string;
+}
+
+export interface Notification {
+ id: string;
+ type: string;
+ channel: string;
+ recipient: string;
+ subject: string;
+ message: string;
+ status: string;
+ certificate_id: string;
+ created_at: string;
+}
+
+export interface AuditEvent {
+ id: string;
+ actor: string;
+ actor_type: string;
+ action: string;
+ resource_type: string;
+ resource_id: string;
+ details: Record;
+ timestamp: string;
+}
+
+export interface PolicyRule {
+ id: string;
+ name: string;
+ type: string;
+ severity: string;
+ config: Record;
+ enabled: boolean;
+ created_at: string;
+ updated_at: string;
+}
+
+export interface PolicyViolation {
+ id: string;
+ rule_id: string;
+ certificate_id: string;
+ severity: string;
+ message: string;
+ created_at: string;
+}
+
+export interface Issuer {
+ id: string;
+ name: string;
+ type: string;
+ config: Record;
+ status: string;
+ created_at: string;
+}
+
+export interface Target {
+ id: string;
+ name: string;
+ type: string;
+ hostname: string;
+ agent_id: string;
+ config: Record;
+ status: string;
+ created_at: string;
+}
+
+export interface PaginatedResponse {
+ data: T[];
+ total: number;
+ page: number;
+ per_page: number;
+}
diff --git a/web/src/api/utils.ts b/web/src/api/utils.ts
new file mode 100644
index 0000000..a7f3846
--- /dev/null
+++ b/web/src/api/utils.ts
@@ -0,0 +1,37 @@
+export function formatDate(iso: string): string {
+ if (!iso) return '—';
+ return new Date(iso).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' });
+}
+
+export function formatDateTime(iso: string): string {
+ if (!iso) return '—';
+ return new Date(iso).toLocaleString('en-US', { year: 'numeric', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' });
+}
+
+export function timeAgo(iso: string): string {
+ if (!iso) return '—';
+ const now = Date.now();
+ const then = new Date(iso).getTime();
+ const diff = now - then;
+ const mins = Math.floor(diff / 60000);
+ if (mins < 1) return 'just now';
+ if (mins < 60) return `${mins}m ago`;
+ const hours = Math.floor(mins / 60);
+ if (hours < 24) return `${hours}h ago`;
+ const days = Math.floor(hours / 24);
+ if (days < 30) return `${days}d ago`;
+ return formatDate(iso);
+}
+
+export function daysUntil(iso: string): number {
+ if (!iso) return 0;
+ return Math.ceil((new Date(iso).getTime() - Date.now()) / 86400000);
+}
+
+export function expiryColor(days: number): string {
+ if (days <= 0) return 'text-red-400';
+ if (days <= 7) return 'text-red-400';
+ if (days <= 14) return 'text-amber-400';
+ if (days <= 30) return 'text-amber-300';
+ return 'text-emerald-400';
+}
diff --git a/web/src/components/DataTable.tsx b/web/src/components/DataTable.tsx
new file mode 100644
index 0000000..5518bd7
--- /dev/null
+++ b/web/src/components/DataTable.tsx
@@ -0,0 +1,69 @@
+interface Column {
+ key: string;
+ label: string;
+ render: (item: T) => React.ReactNode;
+ className?: string;
+}
+
+interface DataTableProps {
+ columns: Column[];
+ data: T[];
+ onRowClick?: (item: T) => void;
+ emptyMessage?: string;
+ isLoading?: boolean;
+}
+
+export default function DataTable({ columns, data, onRowClick, emptyMessage, isLoading }: DataTableProps) {
+ if (isLoading) {
+ return (
+
+ );
+ }
+
+ if (!data.length) {
+ return (
+
+ {emptyMessage || 'No data found'}
+
+ );
+ }
+
+ return (
+
+
+
+
+ {columns.map(col => (
+ |
+ {col.label}
+ |
+ ))}
+
+
+
+ {data.map((item, i) => (
+ onRowClick?.(item)}
+ className={`border-b border-slate-700/50 transition-colors hover:bg-blue-500/5 ${onRowClick ? 'cursor-pointer' : ''}`}
+ >
+ {columns.map(col => (
+ |
+ {col.render(item)}
+ |
+ ))}
+
+ ))}
+
+
+
+ );
+}
+
+export type { Column };
diff --git a/web/src/components/ErrorState.tsx b/web/src/components/ErrorState.tsx
new file mode 100644
index 0000000..18b40e9
--- /dev/null
+++ b/web/src/components/ErrorState.tsx
@@ -0,0 +1,21 @@
+interface ErrorStateProps {
+ error: Error;
+ onRetry?: () => void;
+}
+
+export default function ErrorState({ error, onRetry }: ErrorStateProps) {
+ return (
+
+
+
Failed to load data
+
{error.message}
+ {onRetry && (
+
+ )}
+
+ );
+}
diff --git a/web/src/components/Layout.tsx b/web/src/components/Layout.tsx
new file mode 100644
index 0000000..fc4c913
--- /dev/null
+++ b/web/src/components/Layout.tsx
@@ -0,0 +1,60 @@
+import { NavLink, Outlet } from 'react-router-dom';
+
+const nav = [
+ { to: '/', label: 'Dashboard', icon: 'M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-4 0h4' },
+ { to: '/certificates', label: 'Certificates', icon: 'M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z' },
+ { to: '/agents', label: 'Agents', icon: 'M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2' },
+ { to: '/jobs', label: 'Jobs', icon: 'M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15' },
+ { to: '/notifications', label: 'Notifications', icon: 'M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9' },
+ { to: '/policies', label: 'Policies', icon: 'M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4' },
+ { to: '/audit', label: 'Audit Trail', icon: 'M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z' },
+];
+
+function Icon({ d }: { d: string }) {
+ return (
+
+ );
+}
+
+export default function Layout() {
+ return (
+
+ {/* Sidebar */}
+
+
+ {/* Main content */}
+
+
+
+
+ );
+}
diff --git a/web/src/components/PageHeader.tsx b/web/src/components/PageHeader.tsx
new file mode 100644
index 0000000..5f80a9c
--- /dev/null
+++ b/web/src/components/PageHeader.tsx
@@ -0,0 +1,17 @@
+interface PageHeaderProps {
+ title: string;
+ subtitle?: string;
+ action?: React.ReactNode;
+}
+
+export default function PageHeader({ title, subtitle, action }: PageHeaderProps) {
+ return (
+
+
+
{title}
+ {subtitle &&
{subtitle}
}
+
+ {action}
+
+ );
+}
diff --git a/web/src/components/StatusBadge.tsx b/web/src/components/StatusBadge.tsx
new file mode 100644
index 0000000..f42b168
--- /dev/null
+++ b/web/src/components/StatusBadge.tsx
@@ -0,0 +1,29 @@
+const statusStyles: Record = {
+ Active: 'badge-success',
+ Expiring: 'badge-warning',
+ Expired: 'badge-danger',
+ RenewalInProgress: 'badge-info',
+ PendingIssuance: 'badge-info',
+ Archived: 'badge-neutral',
+ Revoked: 'badge-danger',
+ // Job statuses
+ Pending: 'badge-info',
+ Running: 'badge-warning',
+ Completed: 'badge-success',
+ Failed: 'badge-danger',
+ Cancelled: 'badge-neutral',
+ // Agent statuses
+ Online: 'badge-success',
+ Offline: 'badge-danger',
+ Stale: 'badge-warning',
+ // Notification statuses
+ sent: 'badge-success',
+ pending: 'badge-warning',
+ failed: 'badge-danger',
+ read: 'badge-neutral',
+};
+
+export default function StatusBadge({ status }: { status: string }) {
+ const cls = statusStyles[status] || 'badge-neutral';
+ return {status};
+}
diff --git a/web/src/index.css b/web/src/index.css
new file mode 100644
index 0000000..dd1c20c
--- /dev/null
+++ b/web/src/index.css
@@ -0,0 +1,30 @@
+@tailwind base;
+@tailwind components;
+@tailwind utilities;
+
+@layer base {
+ body {
+ @apply bg-slate-900 text-slate-100 antialiased;
+ }
+}
+
+@layer components {
+ .badge {
+ @apply inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium;
+ }
+ .badge-success { @apply bg-emerald-500/10 text-emerald-400 border border-emerald-500/20; }
+ .badge-warning { @apply bg-amber-500/10 text-amber-400 border border-amber-500/20; }
+ .badge-danger { @apply bg-red-500/10 text-red-400 border border-red-500/20; }
+ .badge-info { @apply bg-blue-500/10 text-blue-400 border border-blue-500/20; }
+ .badge-neutral { @apply bg-slate-500/10 text-slate-400 border border-slate-500/20; }
+
+ .card {
+ @apply bg-slate-800 border border-slate-700 rounded-lg;
+ }
+
+ .btn {
+ @apply inline-flex items-center justify-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-colors;
+ }
+ .btn-primary { @apply bg-blue-600 hover:bg-blue-500 text-white; }
+ .btn-ghost { @apply hover:bg-slate-700 text-slate-300; }
+}
diff --git a/web/src/main.tsx b/web/src/main.tsx
new file mode 100644
index 0000000..d4d12cd
--- /dev/null
+++ b/web/src/main.tsx
@@ -0,0 +1,45 @@
+import { StrictMode } from 'react';
+import { createRoot } from 'react-dom/client';
+import { BrowserRouter, Routes, Route } from 'react-router-dom';
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+import Layout from './components/Layout';
+import DashboardPage from './pages/DashboardPage';
+import CertificatesPage from './pages/CertificatesPage';
+import CertificateDetailPage from './pages/CertificateDetailPage';
+import AgentsPage from './pages/AgentsPage';
+import JobsPage from './pages/JobsPage';
+import NotificationsPage from './pages/NotificationsPage';
+import PoliciesPage from './pages/PoliciesPage';
+import AuditPage from './pages/AuditPage';
+import './index.css';
+
+const queryClient = new QueryClient({
+ defaultOptions: {
+ queries: {
+ staleTime: 10_000,
+ retry: 1,
+ refetchOnWindowFocus: true,
+ },
+ },
+});
+
+createRoot(document.getElementById('root')!).render(
+
+
+
+
+ }>
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+
+
+
+
+
+);
diff --git a/web/src/pages/AgentsPage.tsx b/web/src/pages/AgentsPage.tsx
new file mode 100644
index 0000000..712ffa6
--- /dev/null
+++ b/web/src/pages/AgentsPage.tsx
@@ -0,0 +1,64 @@
+import { useQuery } from '@tanstack/react-query';
+import { getAgents } from '../api/client';
+import PageHeader from '../components/PageHeader';
+import DataTable from '../components/DataTable';
+import type { Column } from '../components/DataTable';
+import StatusBadge from '../components/StatusBadge';
+import ErrorState from '../components/ErrorState';
+import { timeAgo } from '../api/utils';
+import type { Agent } from '../api/types';
+
+function heartbeatStatus(lastHeartbeat: string): string {
+ if (!lastHeartbeat) return 'Offline';
+ const ago = Date.now() - new Date(lastHeartbeat).getTime();
+ if (ago < 5 * 60 * 1000) return 'Online';
+ if (ago < 15 * 60 * 1000) return 'Stale';
+ return 'Offline';
+}
+
+export default function AgentsPage() {
+ const { data, isLoading, error, refetch } = useQuery({
+ queryKey: ['agents'],
+ queryFn: () => getAgents(),
+ refetchInterval: 15000,
+ });
+
+ const columns: Column[] = [
+ {
+ key: 'name',
+ label: 'Agent',
+ render: (a) => (
+
+ ),
+ },
+ {
+ key: 'status',
+ label: 'Health',
+ render: (a) => ,
+ },
+ { key: 'hostname', label: 'Hostname', render: (a) => {a.hostname || '—'} },
+ { key: 'ip', label: 'IP Address', render: (a) => {a.ip_address || '—'} },
+ { key: 'version', label: 'Version', render: (a) => {a.version || '—'} },
+ {
+ key: 'heartbeat',
+ label: 'Last Heartbeat',
+ render: (a) => {timeAgo(a.last_heartbeat)},
+ },
+ ];
+
+ return (
+ <>
+
+
+ {error ? (
+ refetch()} />
+ ) : (
+
+ )}
+
+ >
+ );
+}
diff --git a/web/src/pages/AuditPage.tsx b/web/src/pages/AuditPage.tsx
new file mode 100644
index 0000000..abd8da3
--- /dev/null
+++ b/web/src/pages/AuditPage.tsx
@@ -0,0 +1,86 @@
+import { useQuery } from '@tanstack/react-query';
+import { getAuditEvents } from '../api/client';
+import PageHeader from '../components/PageHeader';
+import DataTable from '../components/DataTable';
+import type { Column } from '../components/DataTable';
+import ErrorState from '../components/ErrorState';
+import { formatDateTime } from '../api/utils';
+import type { AuditEvent } from '../api/types';
+
+const actionColors: Record = {
+ certificate_created: 'text-emerald-400',
+ renewal_triggered: 'text-blue-400',
+ renewal_job_created: 'text-blue-400',
+ renewal_completed: 'text-emerald-400',
+ deployment_completed: 'text-emerald-400',
+ deployment_failed: 'text-red-400',
+ expiration_alert_sent: 'text-amber-400',
+ agent_registered: 'text-blue-400',
+ policy_violated: 'text-red-400',
+};
+
+export default function AuditPage() {
+ const { data, isLoading, error, refetch } = useQuery({
+ queryKey: ['audit'],
+ queryFn: () => getAuditEvents(),
+ refetchInterval: 30000,
+ });
+
+ const columns: Column[] = [
+ {
+ key: 'action',
+ label: 'Action',
+ render: (e) => (
+
+ {e.action.replace(/_/g, ' ')}
+
+ ),
+ },
+ {
+ key: 'actor',
+ label: 'Actor',
+ render: (e) => (
+
+
{e.actor}
+
{e.actor_type}
+
+ ),
+ },
+ {
+ key: 'resource',
+ label: 'Resource',
+ render: (e) => (
+
+
{e.resource_type}
+
{e.resource_id}
+
+ ),
+ },
+ {
+ key: 'details',
+ label: 'Details',
+ render: (e) => {
+ if (!e.details || Object.keys(e.details).length === 0) return —;
+ return (
+
+ {JSON.stringify(e.details).slice(0, 60)}
+
+ );
+ },
+ },
+ { key: 'time', label: 'Time', render: (e) => {formatDateTime(e.timestamp)} },
+ ];
+
+ return (
+ <>
+
+
+ {error ? (
+ refetch()} />
+ ) : (
+
+ )}
+
+ >
+ );
+}
diff --git a/web/src/pages/CertificateDetailPage.tsx b/web/src/pages/CertificateDetailPage.tsx
new file mode 100644
index 0000000..0bcd8f6
--- /dev/null
+++ b/web/src/pages/CertificateDetailPage.tsx
@@ -0,0 +1,168 @@
+import { useParams, useNavigate } from 'react-router-dom';
+import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
+import { getCertificate, getCertificateVersions, triggerRenewal } from '../api/client';
+import PageHeader from '../components/PageHeader';
+import StatusBadge from '../components/StatusBadge';
+import ErrorState from '../components/ErrorState';
+import { formatDate, formatDateTime, daysUntil, expiryColor } from '../api/utils';
+
+function InfoRow({ label, value }: { label: string; value: React.ReactNode }) {
+ return (
+
+ {label}
+ {value}
+
+ );
+}
+
+export default function CertificateDetailPage() {
+ const { id } = useParams<{ id: string }>();
+ const navigate = useNavigate();
+ const queryClient = useQueryClient();
+
+ const { data: cert, isLoading, error, refetch } = useQuery({
+ queryKey: ['certificate', id],
+ queryFn: () => getCertificate(id!),
+ enabled: !!id,
+ });
+
+ const { data: versions } = useQuery({
+ queryKey: ['certificate-versions', id],
+ queryFn: () => getCertificateVersions(id!),
+ enabled: !!id,
+ });
+
+ const renewMutation = useMutation({
+ mutationFn: () => triggerRenewal(id!),
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ['certificate', id] });
+ queryClient.invalidateQueries({ queryKey: ['certificates'] });
+ },
+ });
+
+ if (isLoading) {
+ return (
+ <>
+
+ Loading...
+ >
+ );
+ }
+
+ if (error || !cert) {
+ return (
+ <>
+
+ refetch()} />
+ >
+ );
+ }
+
+ const days = daysUntil(cert.expires_at);
+
+ return (
+ <>
+
+
+
+
+ }
+ />
+
+ {renewMutation.isSuccess && (
+
+ Renewal triggered successfully. A renewal job has been created.
+
+ )}
+ {renewMutation.isError && (
+
+ Failed to trigger renewal: {(renewMutation.error as Error).message}
+
+ )}
+
+
+ {/* Certificate Info */}
+
+
Certificate Details
+ } />
+
+
+
+ {cert.fingerprint.slice(0, 24)}... : '—'
+ } />
+
+
+
+
+ {/* Lifecycle */}
+
+
Lifecycle
+
+
+ {formatDate(cert.expires_at)} ({days <= 0 ? 'expired' : `${days} days`})
+
+ } />
+
+
+
+
+
+
+
+
+
+
+ {/* Tags */}
+ {cert.tags && Object.keys(cert.tags).length > 0 && (
+
+
Tags
+
+ {Object.entries(cert.tags).map(([k, v]) => (
+ {k}: {v}
+ ))}
+
+
+ )}
+
+ {/* Version History */}
+
+
+ Version History {versions?.data?.length ? `(${versions.data.length})` : ''}
+
+ {!versions?.data?.length ? (
+
No versions yet
+ ) : (
+
+ {versions.data.map((v) => (
+
+
+
Version {v.version}
+
{v.serial_number}
+
+
+
{formatDate(v.not_before)} — {formatDate(v.not_after)}
+
{formatDateTime(v.created_at)}
+
+
+ ))}
+
+ )}
+
+
+ >
+ );
+}
diff --git a/web/src/pages/CertificatesPage.tsx b/web/src/pages/CertificatesPage.tsx
new file mode 100644
index 0000000..4e12f43
--- /dev/null
+++ b/web/src/pages/CertificatesPage.tsx
@@ -0,0 +1,103 @@
+import { useState } from 'react';
+import { useQuery } from '@tanstack/react-query';
+import { useNavigate } from 'react-router-dom';
+import { getCertificates } from '../api/client';
+import PageHeader from '../components/PageHeader';
+import DataTable from '../components/DataTable';
+import type { Column } from '../components/DataTable';
+import StatusBadge from '../components/StatusBadge';
+import ErrorState from '../components/ErrorState';
+import { formatDate, daysUntil, expiryColor } from '../api/utils';
+import type { Certificate } from '../api/types';
+
+export default function CertificatesPage() {
+ const navigate = useNavigate();
+ const [statusFilter, setStatusFilter] = useState('');
+ const [envFilter, setEnvFilter] = useState('');
+
+ const params: Record = {};
+ if (statusFilter) params.status = statusFilter;
+ if (envFilter) params.environment = envFilter;
+
+ const { data, isLoading, error, refetch } = useQuery({
+ queryKey: ['certificates', params],
+ queryFn: () => getCertificates(params),
+ refetchInterval: 30000,
+ });
+
+ const columns: Column[] = [
+ {
+ key: 'name',
+ label: 'Certificate',
+ render: (c) => (
+
+
{c.common_name}
+
{c.id}
+
+ ),
+ },
+ { key: 'status', label: 'Status', render: (c) => },
+ {
+ key: 'expires',
+ label: 'Expires',
+ render: (c) => {
+ const days = daysUntil(c.expires_at);
+ return (
+
+
{formatDate(c.expires_at)}
+
{days <= 0 ? 'Expired' : `${days} days`}
+
+ );
+ },
+ },
+ { key: 'env', label: 'Environment', render: (c) => {c.environment || '—'} },
+ { key: 'issuer', label: 'Issuer', render: (c) => {c.issuer_id} },
+ { key: 'owner', label: 'Owner', render: (c) => {c.owner_id} },
+ ];
+
+ return (
+ <>
+
+
+
+
+
+
+ {error ? (
+ refetch()} />
+ ) : (
+ navigate(`/certificates/${c.id}`)}
+ emptyMessage="No certificates found"
+ />
+ )}
+
+ >
+ );
+}
diff --git a/web/src/pages/DashboardPage.tsx b/web/src/pages/DashboardPage.tsx
new file mode 100644
index 0000000..a29881a
--- /dev/null
+++ b/web/src/pages/DashboardPage.tsx
@@ -0,0 +1,144 @@
+import { useQuery } from '@tanstack/react-query';
+import { useNavigate } from 'react-router-dom';
+import { getCertificates, getAgents, getJobs, getNotifications, getHealth } from '../api/client';
+import PageHeader from '../components/PageHeader';
+import StatusBadge from '../components/StatusBadge';
+import { daysUntil, expiryColor, formatDate } from '../api/utils';
+
+function StatCard({ label, value, icon, color }: { label: string; value: string | number; icon: string; color: string }) {
+ const colorMap: Record = {
+ success: 'bg-emerald-500/10 text-emerald-400',
+ warning: 'bg-amber-500/10 text-amber-400',
+ danger: 'bg-red-500/10 text-red-400',
+ info: 'bg-blue-500/10 text-blue-400',
+ };
+ return (
+
+ );
+}
+
+export default function DashboardPage() {
+ const navigate = useNavigate();
+
+ const { data: health } = useQuery({ queryKey: ['health'], queryFn: getHealth, refetchInterval: 30000 });
+ const { data: certs } = useQuery({ queryKey: ['certificates', {}], queryFn: () => getCertificates(), refetchInterval: 30000 });
+ const { data: agents } = useQuery({ queryKey: ['agents'], queryFn: () => getAgents(), refetchInterval: 15000 });
+ const { data: jobs } = useQuery({ queryKey: ['jobs', {}], queryFn: () => getJobs(), refetchInterval: 10000 });
+ const { data: notifs } = useQuery({ queryKey: ['notifications'], queryFn: () => getNotifications() });
+
+ const totalCerts = certs?.total || 0;
+ const expiringSoon = certs?.data?.filter(c => {
+ const d = daysUntil(c.expires_at);
+ return d > 0 && d <= 30;
+ }).length || 0;
+ const expired = certs?.data?.filter(c => c.status === 'Expired' || daysUntil(c.expires_at) <= 0).length || 0;
+ const activeAgents = agents?.data?.filter(a => a.status === 'Online').length || agents?.total || 0;
+ const pendingJobs = jobs?.data?.filter(j => j.status === 'Pending' || j.status === 'Running').length || 0;
+
+ return (
+ <>
+
+
+ {/* Stats */}
+
+
+ 0 ? 'warning' : 'success'}
+ icon="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
+ 0 ? 'danger' : 'success'}
+ icon="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
+
+
+
+
+ {/* Expiring Certificates */}
+
+
+
Certificates Expiring Soon
+
+
+ {!certs?.data?.length ? (
+
No certificates
+ ) : (
+
+ {certs.data
+ .filter(c => c.status !== 'Archived')
+ .sort((a, b) => new Date(a.expires_at).getTime() - new Date(b.expires_at).getTime())
+ .slice(0, 5)
+ .map(c => {
+ const days = daysUntil(c.expires_at);
+ return (
+
navigate(`/certificates/${c.id}`)}
+ className="flex items-center justify-between py-2 px-3 rounded-lg hover:bg-slate-700/50 cursor-pointer transition-colors"
+ >
+
+
{c.common_name}
+
{c.environment || 'no env'}
+
+
+
+ {days <= 0 ? 'Expired' : `${days} days`}
+
+
{formatDate(c.expires_at)}
+
+
+ );
+ })}
+
+ )}
+
+
+ {/* Recent Jobs */}
+
+
+
Recent Jobs
+
+
+ {!jobs?.data?.length ? (
+
No jobs
+ ) : (
+
+ {jobs.data.slice(0, 5).map(j => (
+
+
+
{j.type}
+
{j.certificate_id}
+
+
+
+ ))}
+
+ )}
+
+
+
+ {/* Pending Jobs Banner */}
+ {pendingJobs > 0 && (
+
+
+
{pendingJobs} pending job{pendingJobs > 1 ? 's' : ''}
+
Jobs are waiting to be processed
+
+
+
+ )}
+
+ >
+ );
+}
diff --git a/web/src/pages/JobsPage.tsx b/web/src/pages/JobsPage.tsx
new file mode 100644
index 0000000..2d889f7
--- /dev/null
+++ b/web/src/pages/JobsPage.tsx
@@ -0,0 +1,105 @@
+import { useState } from 'react';
+import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
+import { getJobs, cancelJob } from '../api/client';
+import PageHeader from '../components/PageHeader';
+import DataTable from '../components/DataTable';
+import type { Column } from '../components/DataTable';
+import StatusBadge from '../components/StatusBadge';
+import ErrorState from '../components/ErrorState';
+import { formatDateTime } from '../api/utils';
+import type { Job } from '../api/types';
+
+export default function JobsPage() {
+ const [statusFilter, setStatusFilter] = useState('');
+ const [typeFilter, setTypeFilter] = useState('');
+ const queryClient = useQueryClient();
+
+ const params: Record = {};
+ if (statusFilter) params.status = statusFilter;
+ if (typeFilter) params.type = typeFilter;
+
+ const { data, isLoading, error, refetch } = useQuery({
+ queryKey: ['jobs', params],
+ queryFn: () => getJobs(params),
+ refetchInterval: 10000,
+ });
+
+ const cancelMutation = useMutation({
+ mutationFn: cancelJob,
+ onSuccess: () => queryClient.invalidateQueries({ queryKey: ['jobs'] }),
+ });
+
+ const columns: Column[] = [
+ {
+ key: 'id',
+ label: 'Job',
+ render: (j) => (
+
+ ),
+ },
+ { key: 'status', label: 'Status', render: (j) => },
+ { key: 'cert', label: 'Certificate', render: (j) => {j.certificate_id} },
+ {
+ key: 'attempts',
+ label: 'Attempts',
+ render: (j) => {j.attempts}/{j.max_attempts},
+ },
+ { key: 'scheduled', label: 'Scheduled', render: (j) => {formatDateTime(j.scheduled_at)} },
+ { key: 'completed', label: 'Completed', render: (j) => {formatDateTime(j.completed_at)} },
+ {
+ key: 'actions',
+ label: '',
+ render: (j) => (
+ j.status === 'Pending' || j.status === 'Running' ? (
+
+ ) : null
+ ),
+ },
+ ];
+
+ return (
+ <>
+
+
+
+
+
+
+ {error ? (
+ refetch()} />
+ ) : (
+
+ )}
+
+ >
+ );
+}
diff --git a/web/src/pages/NotificationsPage.tsx b/web/src/pages/NotificationsPage.tsx
new file mode 100644
index 0000000..d963bc9
--- /dev/null
+++ b/web/src/pages/NotificationsPage.tsx
@@ -0,0 +1,48 @@
+import { useQuery } from '@tanstack/react-query';
+import { getNotifications } from '../api/client';
+import PageHeader from '../components/PageHeader';
+import DataTable from '../components/DataTable';
+import type { Column } from '../components/DataTable';
+import StatusBadge from '../components/StatusBadge';
+import ErrorState from '../components/ErrorState';
+import { formatDateTime } from '../api/utils';
+import type { Notification } from '../api/types';
+
+export default function NotificationsPage() {
+ const { data, isLoading, error, refetch } = useQuery({
+ queryKey: ['notifications'],
+ queryFn: () => getNotifications(),
+ refetchInterval: 30000,
+ });
+
+ const columns: Column[] = [
+ {
+ key: 'type',
+ label: 'Type',
+ render: (n) => {n.type.replace(/([A-Z])/g, ' $1').trim()},
+ },
+ { key: 'status', label: 'Status', render: (n) => },
+ { key: 'channel', label: 'Channel', render: (n) => {n.channel} },
+ { key: 'recipient', label: 'Recipient', render: (n) => {n.recipient} },
+ {
+ key: 'message',
+ label: 'Message',
+ render: (n) => {n.message || n.subject},
+ },
+ { key: 'cert', label: 'Certificate', render: (n) => {n.certificate_id || '—'} },
+ { key: 'created', label: 'Sent', render: (n) => {formatDateTime(n.created_at)} },
+ ];
+
+ return (
+ <>
+
+
+ {error ? (
+ refetch()} />
+ ) : (
+
+ )}
+
+ >
+ );
+}
diff --git a/web/src/pages/PoliciesPage.tsx b/web/src/pages/PoliciesPage.tsx
new file mode 100644
index 0000000..0830caa
--- /dev/null
+++ b/web/src/pages/PoliciesPage.tsx
@@ -0,0 +1,64 @@
+import { useQuery } from '@tanstack/react-query';
+import { getPolicies } from '../api/client';
+import PageHeader from '../components/PageHeader';
+import DataTable from '../components/DataTable';
+import type { Column } from '../components/DataTable';
+import ErrorState from '../components/ErrorState';
+import { formatDateTime } from '../api/utils';
+import type { PolicyRule } from '../api/types';
+
+const severityStyles: Record = {
+ low: 'badge-info',
+ medium: 'badge-warning',
+ high: 'badge-danger',
+ critical: 'badge-danger',
+};
+
+export default function PoliciesPage() {
+ const { data, isLoading, error, refetch } = useQuery({
+ queryKey: ['policies'],
+ queryFn: () => getPolicies(),
+ });
+
+ const columns: Column[] = [
+ {
+ key: 'name',
+ label: 'Rule',
+ render: (p) => (
+
+ ),
+ },
+ { key: 'type', label: 'Type', render: (p) => {p.type.replace(/_/g, ' ')} },
+ {
+ key: 'severity',
+ label: 'Severity',
+ render: (p) => {p.severity},
+ },
+ {
+ key: 'enabled',
+ label: 'Enabled',
+ render: (p) => (
+
+ {p.enabled ? 'Yes' : 'No'}
+
+ ),
+ },
+ { key: 'created', label: 'Created', render: (p) => {formatDateTime(p.created_at)} },
+ ];
+
+ return (
+ <>
+
+
+ {error ? (
+ refetch()} />
+ ) : (
+
+ )}
+
+ >
+ );
+}
diff --git a/web/src/vite-env.d.ts b/web/src/vite-env.d.ts
new file mode 100644
index 0000000..11f02fe
--- /dev/null
+++ b/web/src/vite-env.d.ts
@@ -0,0 +1 @@
+///
diff --git a/web/tailwind.config.cjs b/web/tailwind.config.cjs
new file mode 100644
index 0000000..7e045bd
--- /dev/null
+++ b/web/tailwind.config.cjs
@@ -0,0 +1,12 @@
+/** @type {import('tailwindcss').Config} */
+module.exports = {
+ content: [
+ "./index.html",
+ "./src/**/*.{js,ts,jsx,tsx}",
+ ],
+ darkMode: 'class',
+ theme: {
+ extend: {},
+ },
+ plugins: [],
+}
diff --git a/web/tsconfig.json b/web/tsconfig.json
new file mode 100644
index 0000000..75e2ff7
--- /dev/null
+++ b/web/tsconfig.json
@@ -0,0 +1,21 @@
+{
+ "compilerOptions": {
+ "target": "ES2020",
+ "useDefineForClassFields": true,
+ "lib": ["ES2020", "DOM", "DOM.Iterable"],
+ "module": "ESNext",
+ "skipLibCheck": true,
+ "moduleResolution": "bundler",
+ "allowImportingTsExtensions": true,
+ "isolatedModules": true,
+ "moduleDetection": "force",
+ "noEmit": true,
+ "jsx": "react-jsx",
+ "strict": true,
+ "noUnusedLocals": false,
+ "noUnusedParameters": false,
+ "noFallthroughCasesInSwitch": true,
+ "forceConsistentCasingInFileNames": true
+ },
+ "include": ["src"]
+}
diff --git a/web/vite.config.ts b/web/vite.config.ts
new file mode 100644
index 0000000..610acf5
--- /dev/null
+++ b/web/vite.config.ts
@@ -0,0 +1,17 @@
+import { defineConfig } from 'vite'
+import react from '@vitejs/plugin-react'
+
+export default defineConfig({
+ plugins: [react()],
+ server: {
+ port: 5173,
+ proxy: {
+ '/api': 'http://localhost:8443',
+ '/health': 'http://localhost:8443',
+ }
+ },
+ build: {
+ outDir: 'dist',
+ sourcemap: false,
+ }
+})