diff --git a/internal/api.go b/internal/api.go index 4849cae..54697f8 100644 --- a/internal/api.go +++ b/internal/api.go @@ -145,6 +145,7 @@ mux.HandleFunc("/issue-certificate", api.issueCertificateHandler) mux.HandleFunc("/parse-certificate", api.parseCertificateHandler) mux.HandleFunc("/check-certificate-validity", api.checkCertificateValidityHandler) + mux.HandleFunc("/get-certificate", api.getCertificateHandler) // Renew Certificate Handler mux.HandleFunc("/renew-certificate", api.renewCertificateHandler) diff --git a/internal/api_handlers.go b/internal/api_handlers.go index 58449ff..d11dfcd 100644 --- a/internal/api_handlers.go +++ b/internal/api_handlers.go @@ -626,6 +626,54 @@ w.WriteHeader(http.StatusOK) } +func (api *API) getCertificateHandler(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + + domain := r.URL.Query().Get("domain") + secretName := r.URL.Query().Get("secret_name") + + if domain == "" && secretName == "" { + http.Error(w, "either domain or secret_name query parameter required", http.StatusBadRequest) + return + } + if domain != "" && secretName != "" { + http.Error(w, "only one of domain or secret_name query parameter should be provided", http.StatusBadRequest) + return + } + + var cert *storage.CertStorage + var err error + if domain != "" { + cert, err = api.Manager.DB.LoadCertificate(context.Background(), domain) + if err != nil { + http.Error(w, fmt.Sprintf("failed to load certificate for domain %s: %v", domain, err), http.StatusInternalServerError) + return + } + if cert == nil { + http.Error(w, fmt.Sprintf("no certificate found for domain %s", domain), http.StatusNotFound) + return + } + } + if secretName != "" { + cert, err = api.Manager.DB.LoadCertificateBySecretName(context.Background(), secretName) + if err != nil { + http.Error(w, fmt.Sprintf("failed to load certificate for secret name %s: %v", secretName, err), http.StatusInternalServerError) + return + } + if cert == nil { + http.Error(w, fmt.Sprintf("no certificate found for secret name %s", secretName), http.StatusNotFound) + return + } + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(cert) + w.WriteHeader(http.StatusOK) +} + func (api *API) storageDumpHandler(w http.ResponseWriter, r *http.Request) { ctx := context.Background() @@ -819,9 +867,10 @@ for _, cert := range certs { if cert.EnableRotation { rotatingCerts = append(rotatingCerts, &internalapi.RotatingCertificateInfo{ - Domain: cert.Domain, - SecretName: cert.SecretName, - RenewBefore: cert.RenewBefore.String(), + Domain: cert.Domain, + SecretName: cert.SecretName, + RenewBefore: cert.RenewBefore.String(), + RotationEnabled: true, }) } } diff --git a/internal/pkg/storage/storage.go b/internal/pkg/storage/storage.go index d54bc94..3111a95 100644 --- a/internal/pkg/storage/storage.go +++ b/internal/pkg/storage/storage.go @@ -174,6 +174,42 @@ return cert, nil } +func (s *Storage) LoadCertificateBySecretName(ctx context.Context, secretName string) (*CertStorage, error) { + // We expect one result, similar to LoadCertificate, but querying by secret_name. + // Use placeholder(1) and let the strategy handle the SQL dialect + query := fmt.Sprintf(`SELECT domain, email, cert_pem, key_pem, account_key, account_url, issuer_type, secret_name, enable_rotation, renew_before FROM certificates WHERE secret_name = %s`, s.placeholder(1)) + + row := s.db.QueryRowContext(ctx, query, secretName) + + cert := &CertStorage{} + var renewBeforeNanos int64 + + err := row.Scan( + &cert.Domain, + &cert.Email, + &cert.CertPEM, + &cert.KeyPEM, + &cert.AccountKey, + &cert.AccountURL, + &cert.IssuerType, + &cert.SecretName, + &cert.EnableRotation, + &renewBeforeNanos, + ) + + cert.RenewBefore = time.Duration(renewBeforeNanos) + + if errors.Is(err, sql.ErrNoRows) { + // Return a specific error if no certificate is found + return nil, fmt.Errorf("certificate with secret name %s not found", secretName) + } + if err != nil { + return nil, fmt.Errorf("failed to scan certificate data for secret %s: %w", secretName, err) + } + + return cert, nil +} + // UpdateCertRotationSettings updates the enable_rotation and renew_before fields // for a specific certificate domain. func (s *Storage) UpdateCertRotationSettings(ctx context.Context, cert *CertStorage) error { diff --git a/static/global.js b/static/global.js index b1c955a..894808b 100644 --- a/static/global.js +++ b/static/global.js @@ -61,31 +61,49 @@ window.hideAddClusterModal?.(); window.hideAddSecretModal?.(); window.hideCertificateDetailsModal?.() + window.hideRotationSettingsModal?.(); } }); -// Close modal when clicking outside of the content (on the backdrop) window.addEventListener('click', (event) => { - const modal = document.getElementById('configModal'); - const secretModal = document.getElementById('secretConfigModal'); - const addFCModal = document.getElementById('addFilterChainModal'); - const addListenerModal = document.getElementById('addListenerModal'); - const addClusterModal = document.getElementById('addClusterModal'); - - if (event.target === modal) { - hideModal(); - } - if (event.target === secretModal) { - document.getElementById('secretConfigModal').style.display = 'none'; - } - if (event.target === addFCModal) { - window.hideAddFilterChainModal?.(); - } - if (event.target === addListenerModal) { - window.hideAddListenerModal?.(); - } - if (event.target === addClusterModal) { - window.hideAddClusterModal?.(); + // 1. Check if the clicked element has the 'modal' class (i.e., is a backdrop) + if (event.target.classList.contains('modal')) { + const modalId = event.target.id; + + // 2. Map the modal ID to its corresponding close function + switch (modalId) { + case 'configModal': + // The general configuration/details modal + hideModal(); + break; + case 'secretConfigModal': + // Note: The HTML provided doesn't show this ID, but it's in your original JS. + document.getElementById('secretConfigModal').style.display = 'none'; + break; + case 'addFilterChainModal': + window.hideAddFilterChainModal?.(); + break; + case 'addListenerModal': + window.hideAddListenerModal?.(); + break; + case 'addClusterModal': + window.hideAddClusterModal?.(); + break; + case 'addSecretModal': + window.hideAddSecretModal?.(); + break; + case 'certificateDetailsModal': + window.hideCertificateDetailsModal?.(); + break; + case 'rotationSettingsModal': + window.hideRotationSettingsModal?.(); + break; + case 'consistencyModal': + // Note: The HTML has an inline onclick, but for consistency, we call the global function. + window.hideConsistencyModal?.(); + break; + // Add any future modal IDs here + } } }); @@ -556,7 +574,7 @@ import { loadAllData } from './data_loader.js'; import {showDomainConfig} from '/data_fetchers.js' import {resolveConsistency} from './consistency.js' -import { listSecrets,showAddSecretModal ,hideAddSecretModal, disableSecret, enableSecret, submitNewSecret, removeSecret, manualRenewCertificateExport} from './secrets.js'; +import { listSecrets,showAddSecretModal ,hideAddSecretModal, disableSecret, enableSecret, submitNewSecret, removeSecret, manualRenewCertificate, hideRotationSettingsModal} from './secrets.js'; import { showListenerConfigModal ,showClusterConfigModal,showSecretConfigModal} from './modals.js'; import { listListeners, removeFilterChainByRef, disableListener, enableListener, removeListener, showAddListenerModal, hideAddListenerModal, submitNewListener } from './listeners.js'; window.listClusters = listClusters; @@ -579,7 +597,8 @@ window.showSecretConfigModal = showSecretConfigModal; window.submitNewSecret = submitNewSecret; window.removeSecret = removeSecret; -window.manualRenewCertificateExport = manualRenewCertificateExport; +window.manualRenewCertificate = manualRenewCertificate; +window.hideRotationSettingsModal = hideRotationSettingsModal; window.listListeners = listListeners; window.removeFilterChainByRef = removeFilterChainByRef; diff --git a/static/index.html b/static/index.html index c482852..247b409 100644 --- a/static/index.html +++ b/static/index.html @@ -354,6 +354,56 @@ + + diff --git a/static/secrets.js b/static/secrets.js index c52c53d..08c5678 100644 --- a/static/secrets.js +++ b/static/secrets.js @@ -1,9 +1,5 @@ -// secrets.js import { API_BASE_URL, configStore, cleanupConfigStore } from './global.js'; -// ========================================================================= -// SECRET UTILITIES (Unchanged) -// ========================================================================= /** * Extracts a concise description of the secret type (e.g., TlsCertificate). @@ -34,6 +30,41 @@ typeDetails += ` ${statusText}`; } +// NEW: Append rotation status for TLS Certificates with HOVER DETAILS + if (typeName === 'TlsCertificate' && secret.rotationStatus === 'Enabled') { + const rotationInfo = secret.rotationDetails; + let tooltipContent = 'Auto Rotation Enabled.'; // Renamed from tooltipText to tooltipContent + + if (rotationInfo) { + // 1. Convert renew_before to display format + let renewBeforeDisplay = rotationInfo.renew_before || 'N/A'; + const hoursMatch = renewBeforeDisplay.match(/(\d+)h/); + if (hoursMatch) { + const hours = parseInt(hoursMatch[1], 10); + const days = Math.round(hours / 24); + renewBeforeDisplay = `${days}d (${hours}h)`; + } + + const rotationStatusText = rotationInfo.rotation_enabled ? 'Active' : 'Configured (Disabled)'; + const expiresAtText = rotationInfo.expires_at || 'N/A'; + + // 2. Assemble the full tooltip content with actual newlines + // This content will be used by our CSS tooltip. + tooltipContent = ` +Rotation Status: ${rotationStatusText} +Domain: ${rotationInfo.domain} +Secret Name: ${rotationInfo.secret_name} +Renew Before: ${renewBeforeDisplay} +Expires At: ${expiresAtText} + `.trim(); + } + + // --- IMPORTANT CHANGE HERE --- + // Remove the 'title' attribute and use 'data-tooltip' instead. + // The 'title' attribute can be kept as a fallback for accessibility, + // but the CSS tooltip will take precedence. + typeDetails += `Auto Rotation Enabled`; + } return typeDetails; } catch { @@ -57,7 +88,7 @@ // ========================================================================= -// CERTIFICATE LOGIC (API & Modal) (Unchanged) +// CERTIFICATE LOGIC (API & Modal) (Modified/New) // ========================================================================= /** @@ -163,7 +194,9 @@ if (modal) { modal.style.display = 'none'; // Clear the input when closing - document.getElementById('certificate-details-content').value = ''; + // NOTE: Changed .value to .innerHTML as 'certificate-details-content' is a container, not an input. + const content = document.getElementById('certificate-details-content'); + if (content) content.innerHTML = ''; } } @@ -172,42 +205,57 @@ window.hideCertificateDetailsModal = hideCertificateDetailsModal; +// ------------------------------------------------------------------------- +// NEW ROTATION SETTINGS LOGIC +// ------------------------------------------------------------------------- + /** - * Core logic to manually renew a TLS certificate. (Unchanged) - * Prompts the user for the domain name. + * Calls the backend API to fetch the current certificate rotation status. * @param {string} secretName - The name of the secret/certificate. + * @returns {Promise} The rotation status as a JSON object. */ -async function manualRenewCertificate(secretName) { - // 1. **NEW FEATURE LOGIC:** Generate suggested domain name - const suggestedDomain = secretName.replace(/_/g, '.'); - - // 2. Get user confirmation and input for the domain name - const domainName = prompt( - `Please enter the domain name associated with secret '${secretName}' to renew its certificate. - \n(Example: ${suggestedDomain})`, - suggestedDomain // <-- ADDED: Use the suggested domain as the default input value - ); - - if (!domainName) { - console.log(`Certificate renewal for secret '${secretName}' cancelled or domain not provided.`); - return; - } - - if (!confirm(`Confirm renewal request for domain: ${domainName} (Secret: ${secretName})?`)) { - return; - } +async function getCertificateRotationStatus(secretName) { + // NOTE: Using a GET request with query parameter, similar to the user's curl example + const url = `${API_BASE_URL}/get-certificate?secret_name=${encodeURIComponent(secretName)}`; - // 3. Prepare API payload - const renewSecretName = secretName; // Use the secret name itself for the 'secret_name' API param - const url = `${API_BASE_URL}/renew-certificate`; + try { + const response = await fetch(url, { method: 'GET' }); + + if (!response.ok) { + const errorText = await response.text(); + let errorMessage = `HTTP Error ${response.status}: ${response.statusText}`; + try { + errorMessage = JSON.parse(errorText).error || JSON.parse(errorText).message || errorText; + } catch { + errorMessage = errorText || errorMessage; + } + throw new Error(errorMessage); + } + + return response.json(); + + } catch (error) { + console.error(`Failed to fetch rotation status for '${secretName}':`, error); + throw new Error(`Failed to load rotation status. Error: ${error.message}`); + } +} + +/** + * Calls the backend API to enable certificate rotation. + * @param {string} secretName - The name of the secret/certificate. + * @param {string} domain - The domain name associated with the certificate. + * @param {string} renewBefore - The Go duration string (e.g., "720h"). + */ +async function enableCertificateRotation(secretName, domain, renewBefore) { + const url = `${API_BASE_URL}/enable-certificate-rotation`; const payload = { - domain: domainName.trim(), // Use user input - secret_name: renewSecretName + domain: domain.trim(), + secret_name: secretName, + renew_before: renewBefore.trim() }; - console.log(`Attempting to renew certificate for domain: ${domainName.trim()} with secret: ${renewSecretName}`); - - // 4. Execute API call with robust error handling + console.log(`Attempting to enable rotation for secret: ${secretName}`, payload); + try { const response = await fetch(url, { method: 'POST', @@ -215,60 +263,339 @@ body: JSON.stringify(payload) }); - // Check if the response is NOT OK (e.g., 4xx, 5xx) if (!response.ok) { - // Try to read the error from the response body, which could be plain text or JSON const errorText = await response.text(); - - // Attempt to parse the error body as JSON to get a cleaner message let errorMessage = `HTTP Error ${response.status}: ${response.statusText}`; try { - const errorJson = JSON.parse(errorText); - // Check for a common 'error' field in the JSON response - errorMessage = errorJson.error || errorJson.message || errorText; + // Try to extract a clean error message from the JSON response + errorMessage = JSON.parse(errorText).error || JSON.parse(errorText).message || errorText; } catch { - // If parsing fails, use the raw text as the message errorMessage = errorText || errorMessage; } - - // Throw a custom error to be caught below and alerted to the user throw new Error(errorMessage); } - - // If successful, attempt to parse the JSON body + const responseBody = await response.json(); + console.log(`Rotation successfully enabled for '${domain}':`, responseBody); + alert(`Certificate rotation successfully enabled for ${domain}! The list will now refresh.`); - console.log(`Certificate for '${domainName.trim()}' successfully renewed:`, responseBody); - alert(`Certificate for ${domainName.trim()} successfully renewed! The list will now refresh.`); - - // Force a UI refresh to show the potentially updated certificate validity status + hideRotationSettingsModal(); cleanupConfigStore(); listSecrets(); } catch (error) { - // This catches the custom error thrown above OR a network/fetch error - console.error(`Failed to renew certificate for '${domainName.trim()}':`, error); - - // Alert the user with the specific error message - alert(`🚨 Renewal failed for ${domainName.trim()}. Error: ${error.message}`); + console.error(`Failed to enable rotation for '${domain}':`, error); + alert(`🚨 Failed to enable rotation for ${domain}. Error: ${error.message}`); } } /** - * UI entrypoint for manual certificate renewal. (Unchanged) + * Calls the backend API to disable certificate rotation. * @param {string} secretName - The name of the secret/certificate. - * @param {Event} event - The click event to stop propagation. */ -export function manualRenewCertificateExport(secretName, event) { - event.stopPropagation(); - manualRenewCertificate(secretName); +async function disableCertificateRotation(secretName, domain) { + const url = `${API_BASE_URL}/disable-certificate-rotation`; + const payload = { secret_name: secretName , domain: domain.trim()}; + + console.log(`Attempting to disable rotation for secret: ${secretName}`); + + try { + const response = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload) + }); + + if (!response.ok) { + const errorText = await response.text(); + let errorMessage = `HTTP Error ${response.status}: ${response.statusText}`; + try { + errorMessage = JSON.parse(errorText).error || JSON.parse(errorText).message || errorText; + } catch { + errorMessage = errorText || errorMessage; + } + throw new Error(errorMessage); + } + + console.log(`Rotation successfully disabled for secret: ${secretName}`); + alert(`Certificate rotation successfully disabled for ${secretName}! The list will now refresh.`); + + hideRotationSettingsModal(); + cleanupConfigStore(); + listSecrets(); + + } catch (error) { + console.error(`Failed to disable rotation for secret: ${secretName}`, error); + alert(`🚨 Failed to disable rotation for ${secretName}. Error: ${error.message}`); + } +} + +/** + * Shows the modal for certificate rotation settings. + * @param {string} secretName - The name of the secret. + */ +export async function showRotationSettingsModal(secretName) { + const modal = document.getElementById('rotationSettingsModal'); + const secretNameEl = document.getElementById('rotation-secret-name'); + const statusContainer = document.getElementById('rotation-current-status-container'); + const domainInput = document.getElementById('rotation-domain-input'); + const renewBeforeInput = document.getElementById('rotation-renew-before-input'); + const rotationToggle = document.getElementById('rotation-enable-toggle'); + const submitBtn = document.getElementById('rotation-submit-btn'); + const manualRenewBtn = document.getElementById('rotation-manual-renew-btn'); + + if (!modal || !secretNameEl || !domainInput || !renewBeforeInput || !rotationToggle || !submitBtn || !manualRenewBtn) { + console.error("Rotation settings modal elements not found."); + alert("Rotation settings modal is not properly configured in HTML."); + return; + } + + // Set secret name and show loading state + secretNameEl.textContent = secretName; + const loadingHtml = '

⏳ Loading current rotation status...

'; + + if (!statusContainer) { + alert("HTML is missing the required 'rotation-current-status-container' element."); + return; + } + + statusContainer.innerHTML = loadingHtml; + // Hide the form elements until data is loaded + document.getElementById('rotation-settings-form').style.display = 'none'; + modal.style.display = 'block'; + + try { + const status = await getCertificateRotationStatus(secretName); + + // --- Display Current Status --- + const isEnabled = status.EnableRotation; + const statusClass = isEnabled ? 'valid' : 'invalid'; + const statusText = isEnabled ? 'ENABLED' : 'DISABLED'; + // Get RenewBefore, which is now an integer nanosecond timestamp + const renewBeforeNanos = status.RenewBefore || 0; + + let days = 0; + let renewBeforeDisplay = 'N/A'; + + // CORRECTED LOGIC: Convert nanoseconds to days for display and pre-fill + if (typeof renewBeforeNanos === 'number' && renewBeforeNanos > 0) { + // 1 day = 24 hours * 60 minutes * 60 seconds * 1,000,000,000 nanoseconds + const NANOS_PER_DAY = 86400000000000; + + // Calculate days (use Math.round for setting the input value) + days = Math.round(renewBeforeNanos / NANOS_PER_DAY); + + // For display, format it nicely + const hours = Math.round(renewBeforeNanos / 3600000000000); // 1 hour = 3.6e12 nanos + renewBeforeDisplay = `${days} days (${hours}h)`; + } + + // Fallback for pre-fill if conversion results in 0 or N/A, use default 30 days + const renewBeforeDaysForInput = days > 0 ? days : 30; + + // --- Build Status HTML --- + statusContainer.innerHTML = ` +
+

Rotation Status: ${statusText}

+ ${isEnabled ? + `

Domain: ${status.Domain}

+

Renew Before: ${renewBeforeDisplay}

+

Issuer: ${status.IssuerType}

` + : '

Rotation is not currently configured for this secret.

' + } +
+
+

Change Rotation Settings

+ `; + + // --- Pre-fill & Configure Form --- + + // Domain: Use fetched domain if available, otherwise suggest a domain + domainInput.value = status.Domain || secretName.replace(/_/g, '.'); + + // ADDED: Disable the domain input if rotation is already enabled (unchangeable) + domainInput.disabled = true; // Always disable domain input to prevent changes after initial setup + + // Renew Before: Use calculated days or default + renewBeforeInput.value = renewBeforeDaysForInput; + + // Toggle: Set based on fetched status + rotationToggle.checked = isEnabled; + + // Initial visibility of renew-before group + document.getElementById('renew-before-group').style.display = rotationToggle.checked ? 'block' : 'none'; + + // Re-show form + document.getElementById('rotation-settings-form').style.display = 'block'; + + // Update submit button action to call the correct enable/disable function + submitBtn.onclick = () => { + if (rotationToggle.checked) { + // --- ENABLE LOGIC --- + // Domain validation (simple check) + if (!domainInput.value.trim()) { + alert("Please enter the associated Domain Name."); + domainInput.focus(); + return; + } + + const inputDays = parseInt(renewBeforeInput.value, 10); + + // Validation: Check if renewBefore is within the 1 to 80 day range + if (isNaN(inputDays) || inputDays < 1 || inputDays > 80) { + alert("Please enter a 'Renew Before' value between 1 and 80 days."); + renewBeforeInput.focus(); + return; + } + // Convert input days back to Go duration string (e.g., 30 days -> 720h) + const hours = inputDays * 24; + const renewBeforeDuration = `${hours}h`; + + // Call the API function + enableCertificateRotation(secretName, domainInput.value, renewBeforeDuration); + + } else { + // --- DISABLE LOGIC --- + disableCertificateRotation(secretName ,domainInput.value,); + } + }; + + // Ensure manual renew button uses the secretName from the closure + manualRenewBtn.onclick = () => manualRenewCertificate(secretName); + + + } catch (error) { + statusContainer.innerHTML = ` +

🚨 Could not load rotation status. Error: ${error.message}

+ `; + // Hide form on failure + document.getElementById('rotation-settings-form').style.display = 'none'; + console.error("Rotation Status Load Error:", error); + } +} + +/** + * Hides the rotation settings modal. + */ +export function hideRotationSettingsModal() { + const modal = document.getElementById('rotationSettingsModal'); + if (modal) { + modal.style.display = 'none'; + // Clear status and hide form when closing + const statusContainer = document.getElementById('rotation-current-status-container'); + if (statusContainer) statusContainer.innerHTML = ''; + document.getElementById('rotation-settings-form').style.display = 'none'; + } +} + +// Expose the new functions globally for inline HTML onclick handlers +window.showRotationSettingsModal = showRotationSettingsModal; +window.hideRotationSettingsModal = hideRotationSettingsModal; + +/** + * Calls the backend API to trigger an immediate manual certificate renewal. + * NOTE: Updated to accept secretName as an argument or fallback to the element text. + * @param {string} [secretNameArg] - The name of the secret/certificate to renew (optional). + */ +export async function manualRenewCertificate(secretNameArg) { + // Prefer argument, fallback to element text if available + const secretName = secretNameArg || document.getElementById("rotation-secret-name").textContent; + if (!secretName) { + alert("Error: Could not determine the secret name for manual renewal."); + return; + } + + const url = `${API_BASE_URL}/manual-renew-certificate`; + const payload = { + secret_name: secretName + }; + if (!confirm(`⚠️ WARNING: Are you sure you want to manually renew certificate for secret: ${secretName}? This will attempt to refresh the certificate immediately.`)) { + return; + } + + // Hide the modal while processing + hideRotationSettingsModal(); + + try { + const response = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload) + }); + + if (!response.ok) { + const errorText = await response.text(); + let errorMessage = `HTTP Error ${response.status}: ${response.statusText}`; + try { + errorMessage = JSON.parse(errorText).error || JSON.parse(errorText).message || errorText; + } catch { + errorMessage = errorText || errorMessage; + } + throw new Error(errorMessage); + } + + console.log(`Manual renewal successfully triggered for secret: ${secretName}`); + alert(`✅ Manual certificate renewal triggered for ${secretName}. Refreshing list to check status...`); + + // Refresh the secrets list to show the new status + // NOTE: listSecrets is imported from global.js now + listSecrets(); + + } catch (error) { + console.error(`Failed to trigger manual renewal for '${secretName}':`, error); + alert(`🚨 Failed to trigger manual renewal for ${secretName}. Error: ${error.message}`); + } } // ========================================================================= -// SECRET CORE LOGIC (listSecrets) (Unchanged) +// NEW: Rotation List Logic (Updated for full details) // ========================================================================= +/** + * Calls the backend API to get a list of all secrets with rotation enabled, + * and returns them as a Map of secret names to rotation details for quick lookup. + * @returns {Promise>} A Map containing the rotation details for enabled secrets. + */ +async function getRotatingCertificatesMap() { + const url = `${API_BASE_URL}/list-rotating-certificates`; + + try { + const response = await fetch(url, { method: 'GET' }); + + if (!response.ok) { + console.warn(`Failed to fetch rotating certificate list with status ${response.status}. Assuming none are rotating.`); + return new Map(); + } + + const rotationList = await response.json(); + + if (!Array.isArray(rotationList)) { + console.error("API returned an unexpected format for rotation list:", rotationList); + return new Map(); + } + + // Create a Map of secret_name -> { domain, renew_before, ... } for O(1) lookups + return rotationList.reduce((map, cert) => { + if (cert.rotation_enabled && cert.secret_name) { + map.set(cert.secret_name, cert); + } + return map; + }, new Map()); + + } catch (error) { + console.error("Error fetching rotating certificate list:", error); + return new Map(); // Assume no rotation enabled on failure + } +} + + +// ========================================================================= +// SECRET CORE LOGIC (listSecrets) (Modified) +// ========================================================================= + +// Assuming refreshSecretsList is an alias for listSecrets or a similar function in global.js +// If refreshSecretsList is not defined in global.js, replace it with listSecrets() in all calls. export async function listSecrets() { const tableBody = document.getElementById('secret-table-body'); if (!tableBody) { @@ -280,11 +607,15 @@ 'Loading...'; try { - // The API operations show /list-secrets returns enabled and disabled lists - const response = await fetch(`${API_BASE_URL}/list-secrets`); - if (!response.ok) throw new Error(response.statusText); + // Run three parallel fetches: secrets list, certificate validity checks, and rotation list + const [secretsResponse, rotationMap] = await Promise.all([ + fetch(`${API_BASE_URL}/list-secrets`), + getRotatingCertificatesMap() // Fetch the Map of rotating secrets + ]); + + if (!secretsResponse.ok) throw new Error(secretsResponse.statusText); - const secretResponse = await response.json(); + const secretResponse = await secretsResponse.json(); // Combine enabled and disabled secrets for display let allSecrets = [ @@ -301,9 +632,17 @@ cleanupConfigStore(); // ---------------------------------------------------------------------- - // NEW: Check validity for all TLS certificates concurrently + // NEW: Check validity for all TLS certificates concurrently AND set rotation status const validityChecks = allSecrets.map(async secret => { if (isTlsCertificate(secret)) { + // 1. Check/Set Rotation Status and Details + const rotationDetails = rotationMap.get(secret.name); + if (rotationDetails) { + secret.rotationStatus = 'Enabled'; + secret.rotationDetails = rotationDetails; // Attach the full details + } + + // 2. Check Validity Status // Drill down to the PEM string const certPem = secret.Type?.TlsCertificate?.certificate_chain?.Specifier?.InlineString; @@ -325,8 +664,14 @@ // Store full configs in memory by name configStore.secrets = allSecrets.reduce((acc, s) => { const existingYaml = acc[s.name]?.yaml; - // Also store the validityStatus in the configStore entry for potential future use - acc[s.name] = { ...s.configData, yaml: existingYaml, validityStatus: s.validityStatus }; + // Also store the validityStatus and rotationStatus in the configStore entry for potential future use + acc[s.name] = { + ...s.configData, + yaml: existingYaml, + validityStatus: s.validityStatus, + rotationStatus: s.rotationStatus, + rotationDetails: s.rotationDetails // Store rotation details + }; return acc; }, configStore.secrets); @@ -347,11 +692,11 @@ `; } - // Add 'View Details' and 'Renew Cert' buttons for TLS Certificates + // Add 'View Details' and 'Rotation Setting' buttons for TLS Certificates if (isTlsCertificate(secret)) { actionButtons += ` - + `; } @@ -361,7 +706,7 @@ `${secret.name}`; row.insertCell().textContent = secret.status; - // The validity label is now included in the result of getSecretTypeDetails + // The validity label and NEW rotation label are included in the result of getSecretTypeDetails row.insertCell().innerHTML = getSecretTypeDetails(secret); row.insertCell().innerHTML = actionButtons; }); @@ -425,7 +770,7 @@ } // ========================================================================= -// ADD SECRET LOGIC (showAddSecretModal, hideAddSecretModal, submitNewSecret) +// ADD SECRET LOGIC (showAddSecretModal, hideAddSecretModal, submitNewSecret) (Unchanged) // ========================================================================= /** @@ -442,7 +787,6 @@ document.getElementById('addSecretModal').style.display = 'block'; } - /** * Hides the modal for adding a new secret. * MODIFIED: Clears upsert checkbox on hide. diff --git a/static/style.css b/static/style.css index 2b69652..824a2a7 100644 --- a/static/style.css +++ b/static/style.css @@ -840,4 +840,234 @@ .cert-issuer-button-icon { font-size: 1.1rem; /* Slightly larger plus sign for emphasis */ line-height: 1; -} \ No newline at end of file +} + +/* ============================================================= */ +/* Rotation Settings Modal Specific Styles */ +/* ============================================================= */ + +/* General form group layout for modals */ +.modal-content .form-group { + margin-bottom: 15px; +} + +/* Style for input fields inside modals (Unified for consistency) */ +.modal-content input[type="text"], +.modal-content input[type="number"] { + width: 100%; + padding: 10px; + border: 1px solid var(--border-color); + border-radius: 4px; + box-sizing: border-box; + font-size: 1rem; + transition: border-color 0.2s ease; +} + +.modal-content input[type="text"]:focus, +.modal-content input[type="number"]:focus { + border-color: var(--primary-color); + outline: none; + box-shadow: 0 0 0 0.2rem rgba(13, 110, 253, 0.25); +} + +/* Style for labels (Unified for consistency) */ +.modal-content label { + display: block; + font-weight: 600; + margin-bottom: 5px; + color: var(--text-color); +} + +/* Style for the checkbox/toggle group (Unified) */ +.modal-content .checkbox-group { + display: flex; + align-items: center; + padding-left: 0; +} + +.modal-content .checkbox-group input[type="checkbox"] { + /* Style for the visual toggle or checkbox itself */ + margin-right: 10px; + width: 18px; + height: 18px; + cursor: pointer; +} + +.modal-content .checkbox-group label { + /* Adjust label positioning for checkbox */ + font-weight: 500; + margin-bottom: 0; + cursor: pointer; +} + +/* Hint text below inputs */ +.modal-content .input-hint { + font-size: 0.8rem; + color: var(--secondary-color); + margin-top: 5px; + margin-bottom: 0; + padding-left: 2px; +} + +/* Align the submit button in the footer using the existing .modal-actions */ +#rotationSettingsModal .modal-actions #rotation-submit-btn { + /* Use the standard 'add' style */ + background-color: var(--success-color); + color: white; + font-weight: 500; +} + +#rotationSettingsModal .modal-actions #rotation-submit-btn:hover { + background-color: #157347; +} + +#rotationSettingsModal .modal-actions button.disable { + /* Use the standard 'cancel' style */ + background-color: var(--secondary-color); + color: white; + border-color: var(--secondary-color); +} + +#rotationSettingsModal .modal-actions button.disable:hover { + background-color: #5c636a; +} + +/* Recommended CSS to make the rotation label visible */ +.status-label { + /* Base styles for all status labels */ + display: inline-block; + padding: 2px 6px; + border-radius: 4px; + font-size: 0.75em; + font-weight: bold; + margin-left: 5px; + vertical-align: middle; + white-space: nowrap; + text-transform: uppercase; +} + +.status-label.valid { + background-color: #28a745; /* Green */ + color: white; +} + +.status-label.invalid { + background-color: #dc3545; /* Red */ + color: white; +} + +.status-label.rotation-enabled { + /* This is the key missing style for the new label */ + background-color: #007bff; /* A distinct blue for rotation status */ + color: white; +} + +/* --- Base Status Label Styles (from previous answers) --- */ +.status-label { + display: inline-block; + padding: 2px 6px; + border-radius: 4px; + font-size: 0.75em; + font-weight: bold; + margin-left: 5px; + vertical-align: middle; + white-space: nowrap; + text-transform: uppercase; +} + +.status-label.valid { + background-color: #28a745; /* Green */ + color: white; +} + +.status-label.invalid { + background-color: #dc3545; /* Red */ + color: white; +} + +.status-label.rotation-enabled { + background-color: #007bff; /* Blue */ + color: white; + position: relative; /* Crucial for positioning the tooltip */ + cursor: help; /* Indicates interactivity */ +} + +/* --- Custom Tooltip Styles --- */ +.has-tooltip::before, +.has-tooltip::after { + --tooltip-bg: #333; + --tooltip-text-color: #fff; + --tooltip-border-radius: 5px; + --tooltip-padding: 8px 12px; + --tooltip-font-size: 0.8em; + --tooltip-max-width: 300px; + --tooltip-offset: 10px; /* Distance from the element */ + + position: absolute; + visibility: hidden; + opacity: 0; + transition: opacity 0.3s ease, visibility 0.3s ease; + z-index: 1000; /* Ensure it's above other content */ + pointer-events: none; /* Allows clicks through tooltip if not interacting with it */ +} + +/* Tooltip Content */ +.has-tooltip::before { + content: attr(data-tooltip); /* Get content from data-tooltip attribute */ + background: var(--tooltip-bg); + color: var(--tooltip-text-color); + border-radius: var(--tooltip-border-radius); + padding: var(--tooltip-padding); + font-size: var(--tooltip-font-size); + max-width: var(--tooltip-max-width); + white-space: pre-wrap; /* Preserve newlines and wrap text */ + text-align: left; /* Align text within the tooltip */ + text-transform: none; /* Don't uppercase tooltip text */ + + /* Positioning - centered horizontally, below the element */ + left: 50%; + transform: translateX(-50%) translateY(var(--tooltip-offset)); + bottom: calc(100% + var(--tooltip-offset)); /* Position above the element by default */ + + /* Ensure it doesn't get squished too thin */ + min-width: 150px; +} + +/* Tooltip Arrow */ +.has-tooltip::after { + content: ''; + border-width: var(--tooltip-offset); + border-style: solid; + border-color: var(--tooltip-bg) transparent transparent transparent; /* Arrow pointing up */ + + /* Positioning - centered horizontally, below the content */ + left: 50%; + transform: translateX(-50%) translateY(var(--tooltip-offset)); + bottom: calc(100% + var(--tooltip-offset) - 5px); /* Position slightly below the content */ + + /* Adjust for arrow direction if position changes */ + /* Example: if tooltip is below, arrow points down */ + /* border-color: transparent transparent var(--tooltip-bg) transparent; */ +} + +/* Show tooltip on hover */ +.has-tooltip:hover::before, +.has-tooltip:hover::after { + visibility: visible; + opacity: 1; +} + +/* Adjust tooltip position if it's too close to the right edge (optional, more complex) */ +/* This is a basic example; for robust positioning, a JS library is best */ +/* @media (max-width: 768px) { + .has-tooltip::before, + .has-tooltip::after { + left: 0; + transform: translateY(var(--tooltip-offset)); + right: auto; + bottom: calc(100% + var(--tooltip-offset)); + } + .has-tooltip::after { + left: 10px; // Adjust arrow + } +} */ \ No newline at end of file