UX improvements

This commit is contained in:
overcuriousity 2025-09-17 21:12:11 +02:00
parent d0ee415f0d
commit 8ae4fdbf80
11 changed files with 905 additions and 360 deletions

View File

@ -31,4 +31,4 @@ LARGE_ENTITY_THRESHOLD=100
# The number of times to retry a target if a provider fails. # The number of times to retry a target if a provider fails.
MAX_RETRIES_PER_TARGET=8 MAX_RETRIES_PER_TARGET=8
# How long cached provider responses are stored (in hours). # How long cached provider responses are stored (in hours).
CACHE_EXPIRY_HOURS=12 CACHE_TIMEOUT_HOURS=12

116
app.py
View File

@ -10,6 +10,7 @@ import traceback
from flask import Flask, render_template, request, jsonify, send_file, session from flask import Flask, render_template, request, jsonify, send_file, session
from datetime import datetime, timezone, timedelta from datetime import datetime, timezone, timedelta
import io import io
import os
from core.session_manager import session_manager from core.session_manager import session_manager
from config import config from config import config
@ -304,21 +305,6 @@ def export_results():
traceback.print_exc() traceback.print_exc()
return jsonify({'success': False, 'error': f'Export failed: {str(e)}'}), 500 return jsonify({'success': False, 'error': f'Export failed: {str(e)}'}), 500
@app.route('/api/providers', methods=['GET'])
def get_providers():
"""Get information about available providers."""
try:
user_session_id, scanner = get_user_scanner()
provider_info = scanner.get_provider_info()
return jsonify({'success': True, 'providers': provider_info, 'user_session_id': user_session_id})
except Exception as e:
traceback.print_exc()
return jsonify({'success': False, 'error': f'Internal server error: {str(e)}'}), 500
@app.route('/api/config/api-keys', methods=['POST']) @app.route('/api/config/api-keys', methods=['POST'])
def set_api_keys(): def set_api_keys():
"""Set API keys for the current session.""" """Set API keys for the current session."""
@ -355,6 +341,106 @@ def set_api_keys():
traceback.print_exc() traceback.print_exc()
return jsonify({'success': False, 'error': f'Internal server error: {str(e)}'}), 500 return jsonify({'success': False, 'error': f'Internal server error: {str(e)}'}), 500
@app.route('/api/providers', methods=['GET'])
def get_providers():
"""Get enhanced information about available providers including API key sources."""
try:
user_session_id, scanner = get_user_scanner()
base_provider_info = scanner.get_provider_info()
# Enhance provider info with API key source information
enhanced_provider_info = {}
for provider_name, info in base_provider_info.items():
enhanced_info = dict(info) # Copy base info
if info['requires_api_key']:
# Determine API key source and configuration status
api_key = scanner.config.get_api_key(provider_name)
backend_api_key = os.getenv(f'{provider_name.upper()}_API_KEY')
if backend_api_key:
# API key configured via backend/environment
enhanced_info.update({
'api_key_configured': True,
'api_key_source': 'backend',
'api_key_help': f'API key configured via environment variable {provider_name.upper()}_API_KEY'
})
elif api_key:
# API key configured via web interface
enhanced_info.update({
'api_key_configured': True,
'api_key_source': 'frontend',
'api_key_help': f'API key set via web interface (session-only)'
})
else:
# No API key configured
enhanced_info.update({
'api_key_configured': False,
'api_key_source': None,
'api_key_help': f'Requires API key to enable {info["display_name"]} integration'
})
else:
# Provider doesn't require API key
enhanced_info.update({
'api_key_configured': True, # Always "configured" for non-API providers
'api_key_source': None,
'api_key_help': None
})
enhanced_provider_info[provider_name] = enhanced_info
return jsonify({
'success': True,
'providers': enhanced_provider_info,
'user_session_id': user_session_id
})
except Exception as e:
traceback.print_exc()
return jsonify({'success': False, 'error': f'Internal server error: {str(e)}'}), 500
@app.route('/api/config/providers', methods=['POST'])
def configure_providers():
"""Configure provider settings (enable/disable)."""
try:
data = request.get_json()
if data is None:
return jsonify({'success': False, 'error': 'No provider settings provided'}), 400
user_session_id, scanner = get_user_scanner()
session_config = scanner.config
updated_providers = []
for provider_name, settings in data.items():
provider_name_clean = provider_name.lower().strip()
if 'enabled' in settings:
# Update the enabled state in session config
session_config.enabled_providers[provider_name_clean] = settings['enabled']
updated_providers.append(provider_name_clean)
if updated_providers:
# Reinitialize providers with new settings
scanner._initialize_providers()
session_manager.update_session_scanner(user_session_id, scanner)
return jsonify({
'success': True,
'message': f'Provider settings updated for: {", ".join(updated_providers)}',
'user_session_id': user_session_id
})
else:
return jsonify({'success': False, 'error': 'No valid provider settings were provided.'}), 400
except Exception as e:
traceback.print_exc()
return jsonify({'success': False, 'error': f'Internal server error: {str(e)}'}), 500
@app.errorhandler(404) @app.errorhandler(404)
def not_found(error): def not_found(error):
"""Handle 404 errors.""" """Handle 404 errors."""

View File

@ -25,7 +25,6 @@ class Config:
self.max_concurrent_requests = 1 self.max_concurrent_requests = 1
self.large_entity_threshold = 100 self.large_entity_threshold = 100
self.max_retries_per_target = 8 self.max_retries_per_target = 8
self.cache_expiry_hours = 12
# --- Provider Caching Settings --- # --- Provider Caching Settings ---
self.cache_timeout_hours = 6 # Provider-specific cache timeout self.cache_timeout_hours = 6 # Provider-specific cache timeout
@ -69,7 +68,6 @@ class Config:
self.max_concurrent_requests = int(os.getenv('MAX_CONCURRENT_REQUESTS', self.max_concurrent_requests)) self.max_concurrent_requests = int(os.getenv('MAX_CONCURRENT_REQUESTS', self.max_concurrent_requests))
self.large_entity_threshold = int(os.getenv('LARGE_ENTITY_THRESHOLD', self.large_entity_threshold)) self.large_entity_threshold = int(os.getenv('LARGE_ENTITY_THRESHOLD', self.large_entity_threshold))
self.max_retries_per_target = int(os.getenv('MAX_RETRIES_PER_TARGET', self.max_retries_per_target)) self.max_retries_per_target = int(os.getenv('MAX_RETRIES_PER_TARGET', self.max_retries_per_target))
self.cache_expiry_hours = int(os.getenv('CACHE_EXPIRY_HOURS', self.cache_expiry_hours))
self.cache_timeout_hours = int(os.getenv('CACHE_TIMEOUT_HOURS', self.cache_timeout_hours)) self.cache_timeout_hours = int(os.getenv('CACHE_TIMEOUT_HOURS', self.cache_timeout_hours))
# Override Flask and session settings # Override Flask and session settings
@ -87,6 +85,60 @@ class Config:
self.enabled_providers[provider] = True self.enabled_providers[provider] = True
return True return True
def set_provider_enabled(self, provider: str, enabled: bool) -> bool:
"""
Set provider enabled status for the session.
Args:
provider: Provider name
enabled: Whether the provider should be enabled
Returns:
True if the setting was applied successfully
"""
provider_key = provider.lower()
self.enabled_providers[provider_key] = enabled
return True
def get_provider_enabled(self, provider: str) -> bool:
"""
Get provider enabled status.
Args:
provider: Provider name
Returns:
True if the provider is enabled
"""
provider_key = provider.lower()
return self.enabled_providers.get(provider_key, True) # Default to enabled
def bulk_set_provider_settings(self, provider_settings: dict) -> dict:
"""
Set multiple provider settings at once.
Args:
provider_settings: Dict of provider_name -> {'enabled': bool, ...}
Returns:
Dict with results for each provider
"""
results = {}
for provider_name, settings in provider_settings.items():
provider_key = provider_name.lower()
try:
if 'enabled' in settings:
self.enabled_providers[provider_key] = settings['enabled']
results[provider_key] = {'success': True, 'enabled': settings['enabled']}
else:
results[provider_key] = {'success': False, 'error': 'No enabled setting provided'}
except Exception as e:
results[provider_key] = {'success': False, 'error': str(e)}
return results
def get_api_key(self, provider: str) -> Optional[str]: def get_api_key(self, provider: str) -> Optional[str]:
"""Get API key for a provider.""" """Get API key for a provider."""
return self.api_keys.get(provider) return self.api_keys.get(provider)

View File

@ -4,7 +4,7 @@
Graph data model for DNSRecon using NetworkX. Graph data model for DNSRecon using NetworkX.
Manages in-memory graph storage with confidence scoring and forensic metadata. Manages in-memory graph storage with confidence scoring and forensic metadata.
Now fully compatible with the unified ProviderResult data model. Now fully compatible with the unified ProviderResult data model.
UPDATED: Fixed certificate styling and correlation edge labeling. UPDATED: Fixed correlation exclusion keys to match actual attribute names.
""" """
import re import re
from datetime import datetime, timezone from datetime import datetime, timezone
@ -41,7 +41,30 @@ class GraphManager:
self.correlation_index = {} self.correlation_index = {}
# Compile regex for date filtering for efficiency # Compile regex for date filtering for efficiency
self.date_pattern = re.compile(r'^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}:\d{2}') self.date_pattern = re.compile(r'^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}:\d{2}')
self.EXCLUDED_KEYS = ['crtsh_cert_validity_period_days','crtsh_cert_source','crtsh_cert_common_name']
# These are the actual attribute names created in providers, WITHOUT provider prefix
self.EXCLUDED_KEYS = [
# Certificate metadata that creates noise
'cert_source', # Always 'crtsh' for crtsh provider
'cert_common_name',
'cert_validity_period_days', # Numerical, not useful for correlation
#'cert_certificate_id', # Unique per certificate
#'cert_serial_number', # Unique per certificate
'cert_entry_timestamp', # Timestamp, filtered by date regex anyway
'cert_not_before', # Date, filtered by date regex anyway
'cert_not_after', # Date, filtered by date regex anyway
# DNS metadata that creates noise
'dns_ttl', # TTL values are not meaningful for correlation
# Shodan metadata that might create noise
'timestamp', # Generic timestamp fields
'last_update', # Generic timestamp fields
#'org', # Too generic, causes false correlations
#'isp', # Too generic, causes false correlations
# Generic noisy attributes
'updated_timestamp', # Any timestamp field
'discovery_timestamp', # Any timestamp field
'query_timestamp', # Any timestamp field
]
def __getstate__(self): def __getstate__(self):
"""Prepare GraphManager for pickling, excluding compiled regex.""" """Prepare GraphManager for pickling, excluding compiled regex."""
@ -72,14 +95,31 @@ class GraphManager:
attr_value = attr.get('value') attr_value = attr.get('value')
attr_provider = attr.get('provider', 'unknown') attr_provider = attr.get('provider', 'unknown')
# Skip excluded attributes and invalid values # IMPROVED: More comprehensive exclusion logic
if any(excluded_key in attr_name for excluded_key in self.EXCLUDED_KEYS) or not isinstance(attr_value, (str, int, float, bool)) or attr_value is None: should_exclude = (
continue # Check against excluded keys (exact match or substring)
any(excluded_key in attr_name or attr_name == excluded_key for excluded_key in self.EXCLUDED_KEYS) or
# Invalid value types
not isinstance(attr_value, (str, int, float, bool)) or
attr_value is None or
# Boolean values are not useful for correlation
isinstance(attr_value, bool) or
# String values that are too short or are dates
(isinstance(attr_value, str) and (
len(attr_value) < 4 or
self.date_pattern.match(attr_value) or
# Exclude common generic values that create noise
attr_value.lower() in ['unknown', 'none', 'null', 'n/a', 'true', 'false', '0', '1']
)) or
# Numerical values that are likely to be unique identifiers
(isinstance(attr_value, (int, float)) and (
attr_value == 0 or # Zero values are not meaningful
attr_value == 1 or # One values are too common
abs(attr_value) > 1000000 # Very large numbers are likely IDs
))
)
if isinstance(attr_value, bool): if should_exclude:
continue
if isinstance(attr_value, str) and (len(attr_value) < 4 or self.date_pattern.match(attr_value)):
continue continue
# Initialize correlation tracking for this value # Initialize correlation tracking for this value
@ -149,7 +189,7 @@ class GraphManager:
if self.graph.has_node(node_id) and not self.graph.has_edge(node_id, correlation_node_id): if self.graph.has_node(node_id) and not self.graph.has_edge(node_id, correlation_node_id):
# Format relationship label as "corr_provider_attribute" # Format relationship label as "corr_provider_attribute"
relationship_label = f"{provider}_{attribute}" relationship_label = f"corr_{provider}_{attribute}"
self.add_edge( self.add_edge(
source_id=node_id, source_id=node_id,
@ -170,7 +210,7 @@ class GraphManager:
def _has_direct_edge_bidirectional(self, node_a: str, node_b: str) -> bool: def _has_direct_edge_bidirectional(self, node_a: str, node_b: str) -> bool:
""" """
Check if there's a direct edge between two nodes in either direction. Check if there's a direct edge between two nodes in either direction.
Returns True if node_anode_b OR node_bnode_a exists. Returns True if node_aâ'node_b OR node_bâ†'node_a exists.
""" """
return (self.graph.has_edge(node_a, node_b) or return (self.graph.has_edge(node_a, node_b) or
self.graph.has_edge(node_b, node_a)) self.graph.has_edge(node_b, node_a))
@ -410,12 +450,6 @@ class GraphManager:
"""Get all nodes of a specific type.""" """Get all nodes of a specific type."""
return [n for n, d in self.graph.nodes(data=True) if d.get('type') == node_type.value] return [n for n, d in self.graph.nodes(data=True) if d.get('type') == node_type.value]
def get_neighbors(self, node_id: str) -> List[str]:
"""Get all unique neighbors (predecessors and successors) for a node."""
if not self.graph.has_node(node_id):
return []
return list(set(self.graph.predecessors(node_id)) | set(self.graph.successors(node_id)))
def get_high_confidence_edges(self, min_confidence: float = 0.8) -> List[Tuple[str, str, Dict]]: def get_high_confidence_edges(self, min_confidence: float = 0.8) -> List[Tuple[str, str, Dict]]:
"""Get edges with confidence score above a given threshold.""" """Get edges with confidence score above a given threshold."""
return [(u, v, d) for u, v, d in self.graph.edges(data=True) return [(u, v, d) for u, v, d in self.graph.edges(data=True)

View File

@ -101,6 +101,7 @@ class ProviderResult:
"""Get the total number of attributes in this result.""" """Get the total number of attributes in this result."""
return len(self.attributes) return len(self.attributes)
def is_large_entity(self, threshold: int) -> bool: ##TODO
"""Check if this result qualifies as a large entity based on relationship count.""" #def is_large_entity(self, threshold: int) -> bool:
return self.get_relationship_count() > threshold # """Check if this result qualifies as a large entity based on relationship count."""
# return self.get_relationship_count() > threshold

View File

@ -370,6 +370,7 @@ class Scanner:
task_tuple = (provider_name, target_item) task_tuple = (provider_name, target_item)
if task_tuple in processed_tasks: if task_tuple in processed_tasks:
self.tasks_skipped += 1 self.tasks_skipped += 1
self.indicators_completed +=1
continue continue
if depth > max_depth: if depth > max_depth:
@ -405,7 +406,7 @@ class Scanner:
if self.target_retries[task_tuple] <= self.config.max_retries_per_target: if self.target_retries[task_tuple] <= self.config.max_retries_per_target:
self.task_queue.put((priority, (provider_name, target_item, depth))) self.task_queue.put((priority, (provider_name, target_item, depth)))
self.tasks_re_enqueued += 1 self.tasks_re_enqueued += 1
self.total_tasks_ever_enqueued += 1 #self.total_tasks_ever_enqueued += 1
else: else:
self.scan_failed_due_to_retries = True self.scan_failed_due_to_retries = True
self._log_target_processing_error(str(task_tuple), "Max retries exceeded") self._log_target_processing_error(str(task_tuple), "Max retries exceeded")

View File

@ -108,64 +108,6 @@ class SessionManager:
print(f"ERROR: Failed to create session {session_id}: {e}") print(f"ERROR: Failed to create session {session_id}: {e}")
raise raise
def clone_session_preserving_config(self, source_session_id: str) -> str:
"""
FIXED: Create a new session that preserves the configuration (including API keys) from an existing session.
This is used when we need a fresh scanner but want to keep user configuration.
"""
with self.creation_lock:
print(f"=== CLONING SESSION {source_session_id} (PRESERVING CONFIG) ===")
try:
# Get the source session data
source_session_data = self._get_session_data(source_session_id)
if not source_session_data:
print(f"ERROR: Source session {source_session_id} not found for cloning")
return self.create_session() # Fallback to new session
# Create new session ID
new_session_id = str(uuid.uuid4())
# Get the preserved configuration
preserved_config = source_session_data.get('config')
if not preserved_config:
print(f"WARNING: No config found in source session, creating new")
from core.session_config import create_session_config
preserved_config = create_session_config()
print(f"Preserving config with API keys: {list(preserved_config.api_keys.keys())}")
# Create new scanner with preserved config
new_scanner = Scanner(session_config=preserved_config)
new_scanner.session_id = new_session_id
new_session_data = {
'scanner': new_scanner,
'config': preserved_config,
'created_at': time.time(),
'last_activity': time.time(),
'status': 'active',
'cloned_from': source_session_id
}
# Store in Redis
serialized_data = pickle.dumps(new_session_data)
session_key = self._get_session_key(new_session_id)
self.redis_client.setex(session_key, self.session_timeout, serialized_data)
# Initialize stop signal
stop_key = self._get_stop_signal_key(new_session_id)
self.redis_client.setex(stop_key, self.session_timeout, b'0')
print(f"Cloned session {new_session_id} with preserved configuration")
return new_session_id
except Exception as e:
print(f"ERROR: Failed to clone session {source_session_id}: {e}")
# Fallback to creating a new session
return self.create_session()
def set_stop_signal(self, session_id: str) -> bool: def set_stop_signal(self, session_id: str) -> bool:
""" """
Set the stop signal for a session (cross-process safe). Set the stop signal for a session (cross-process safe).

View File

@ -502,76 +502,6 @@ class CrtShProvider(BaseProvider):
return [d for d in final_domains if _is_valid_domain(d)] return [d for d in final_domains if _is_valid_domain(d)]
def _find_shared_certificates(self, certs1: List[Dict[str, Any]], certs2: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
"""Find certificates that are shared between two domain certificate lists."""
shared = []
cert1_ids = set()
for cert in certs1:
cert_id = cert.get('certificate_id')
if cert_id and isinstance(cert_id, (int, str, float, bool, tuple)):
cert1_ids.add(cert_id)
for cert in certs2:
cert_id = cert.get('certificate_id')
if cert_id and isinstance(cert_id, (int, str, float, bool, tuple)):
if cert_id in cert1_ids:
shared.append(cert)
return shared
def _summarize_certificates(self, certificates: List[Dict[str, Any]]) -> Dict[str, Any]:
"""Create a summary of certificates for a domain."""
if not certificates:
return {
'total_certificates': 0,
'valid_certificates': 0,
'expired_certificates': 0,
'expires_soon_count': 0,
'unique_issuers': [],
'latest_certificate': None,
'has_valid_cert': False,
'certificate_details': []
}
valid_count = sum(1 for cert in certificates if cert.get('is_currently_valid'))
expired_count = len(certificates) - valid_count
expires_soon_count = sum(1 for cert in certificates if cert.get('expires_soon'))
unique_issuers = list(set(cert.get('issuer_name') for cert in certificates if cert.get('issuer_name')))
# Find the most recent certificate
latest_cert = None
latest_date = None
for cert in certificates:
try:
if cert.get('not_before'):
cert_date = self._parse_certificate_date(cert['not_before'])
if latest_date is None or cert_date > latest_date:
latest_date = cert_date
latest_cert = cert
except Exception:
continue
# Sort certificates by date for better display (newest first)
sorted_certificates = sorted(
certificates,
key=lambda c: self._get_certificate_sort_date(c),
reverse=True
)
return {
'total_certificates': len(certificates),
'valid_certificates': valid_count,
'expired_certificates': expired_count,
'expires_soon_count': expires_soon_count,
'unique_issuers': unique_issuers,
'latest_certificate': latest_cert,
'has_valid_cert': valid_count > 0,
'certificate_details': sorted_certificates
}
def _get_certificate_sort_date(self, cert: Dict[str, Any]) -> datetime: def _get_certificate_sort_date(self, cert: Dict[str, Any]) -> datetime:
"""Get a sortable date from certificate data for chronological ordering.""" """Get a sortable date from certificate data for chronological ordering."""
try: try:

View File

@ -2115,3 +2115,184 @@ input[type="text"]:focus, select:focus {
gap: 0.5rem; gap: 0.5rem;
} }
} }
/* Add these styles to the existing main.css file - they use existing design patterns */
/* Settings Modal Enhancements - using existing provider-item styles */
#settings-modal .modal-section {
margin-bottom: 1.5rem;
}
#settings-modal .modal-section:last-child {
margin-bottom: 0;
}
/* Provider configuration section using existing provider-item styling */
#provider-config-list .provider-item {
margin-bottom: 1rem;
background: linear-gradient(135deg, #2a2a2a 0%, #1e1e1e 100%);
border: 1px solid #333;
border-radius: 8px;
transition: all 0.3s ease;
}
#provider-config-list .provider-item:last-child {
margin-bottom: 0;
}
#provider-config-list .provider-item:hover {
border-color: #444;
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
}
/* Provider toggle styling - extends existing checkbox pattern */
.provider-toggle {
appearance: none !important;
width: 16px !important;
height: 16px !important;
border: 2px solid #555 !important;
background: #1a1a1a !important;
cursor: pointer !important;
position: relative !important;
border-radius: 3px !important;
transition: all 0.3s ease !important;
margin: 0 !important;
}
.provider-toggle:checked {
background: #00ff41 !important;
border-color: #00ff41 !important;
}
.provider-toggle:checked::after {
content: '✓' !important;
position: absolute !important;
top: -3px !important;
left: 1px !important;
color: #1a1a1a !important;
font-size: 12px !important;
font-weight: bold !important;
}
.provider-toggle:hover {
border-color: #00ff41 !important;
box-shadow: 0 0 5px rgba(0, 255, 65, 0.3) !important;
}
/* API Key status row styling - uses existing status-row pattern */
.api-key-status-row {
padding: 0.75rem;
border-radius: 4px;
border: 1px solid;
transition: all 0.3s ease;
margin-top: 0.5rem;
}
.api-key-status-row.configured {
background: rgba(0, 255, 65, 0.1);
border-color: rgba(0, 255, 65, 0.3);
}
.api-key-status-row.not-configured {
background: rgba(255, 153, 0, 0.1);
border-color: rgba(255, 153, 0, 0.3);
}
/* Settings modal button styling - uses existing button classes */
#settings-modal .button-group {
padding-top: 1.5rem;
border-top: 1px solid #333;
margin-top: 1.5rem;
}
/* Clear button specific styling - extends btn-secondary */
.clear-api-key-btn {
font-size: 0.8rem !important;
padding: 0.4rem 0.8rem !important;
min-width: auto !important;
}
.clear-api-key-btn:disabled {
opacity: 0.5 !important;
cursor: not-allowed !important;
}
/* Provider configuration checkbox label styling */
#provider-config-list .status-row label {
color: #e0e0e0;
cursor: pointer;
transition: color 0.3s ease;
font-size: 0.9rem;
font-weight: 500;
}
#provider-config-list .status-row label:hover {
color: #00ff41;
}
/* Settings section count badges - uses existing merge-badge styling */
#provider-count,
#api-key-count {
background: #444;
color: #fff;
padding: 0.25rem 0.5rem;
border-radius: 3px;
font-size: 0.8rem;
font-weight: 600;
min-width: 20px;
text-align: center;
}
/* API key help text styling - uses existing patterns */
#settings-modal .apikey-help {
font-size: 0.8rem;
color: #666;
margin-top: 0.5rem;
font-style: italic;
line-height: 1.4;
}
/* Modal section content spacing */
#settings-modal .modal-section-content {
padding: 1rem;
}
#settings-modal .modal-section-content .provider-item {
padding: 1rem;
}
/* Transition effects for API key clearing */
.api-key-clearing {
opacity: 0.6;
pointer-events: none;
transition: all 0.3s ease;
}
.api-key-cleared {
opacity: 0;
transform: translateX(-10px);
transition: all 0.3s ease;
}
/* Settings modal responsiveness - extends existing responsive patterns */
@media (max-width: 768px) {
#settings-modal .modal-content {
width: 95%;
margin: 5% auto;
max-height: 90vh;
}
#provider-config-list .provider-item {
margin-bottom: 0.75rem;
}
#settings-modal .provider-stats {
grid-template-columns: 1fr;
gap: 0.5rem;
}
#settings-modal .button-group {
flex-direction: column;
}
}

View File

@ -34,8 +34,8 @@ class DNSReconApp {
this.updateStatus(); this.updateStatus();
this.loadProviders(); this.loadProviders();
this.initializeEnhancedModals(); this.initializeEnhancedModals();
this.addCheckboxStyling();
// FIXED: Force initial graph update to handle empty sessions properly
this.updateGraph(); this.updateGraph();
console.log('DNSRecon application initialized successfully'); console.log('DNSRecon application initialized successfully');
@ -81,9 +81,7 @@ class DNSReconApp {
// Settings Modal elements // Settings Modal elements
settingsModal: document.getElementById('settings-modal'), settingsModal: document.getElementById('settings-modal'),
settingsModalClose: document.getElementById('settings-modal-close'), settingsModalClose: document.getElementById('settings-modal-close'),
apiKeyInputs: document.getElementById('api-key-inputs'),
saveApiKeys: document.getElementById('save-api-keys'),
resetApiKeys: document.getElementById('reset-api-keys'),
// Other elements // Other elements
sessionId: document.getElementById('session-id'), sessionId: document.getElementById('session-id'),
@ -182,10 +180,21 @@ class DNSReconApp {
}); });
} }
if (this.elements.saveApiKeys) { if (this.elements.saveApiKeys) {
this.elements.saveApiKeys.addEventListener('click', () => this.saveApiKeys()); this.elements.saveApiKeys.removeEventListener('click', this.saveApiKeys);
} }
if (this.elements.resetApiKeys) { if (this.elements.resetApiKeys) {
this.elements.resetApiKeys.addEventListener('click', () => this.resetApiKeys()); this.elements.resetApiKeys.removeEventListener('click', this.resetApiKeys);
}
// Setup new handlers
const saveSettingsBtn = document.getElementById('save-settings');
const resetSettingsBtn = document.getElementById('reset-settings');
if (saveSettingsBtn) {
saveSettingsBtn.addEventListener('click', () => this.saveSettings());
}
if (resetSettingsBtn) {
resetSettingsBtn.addEventListener('click', () => this.resetSettings());
} }
// Listen for the custom event from the graph // Listen for the custom event from the graph
@ -724,7 +733,7 @@ class DNSReconApp {
if (response.success) { if (response.success) {
this.updateProviderDisplay(response.providers); this.updateProviderDisplay(response.providers);
this.buildApiKeyModal(response.providers); this.buildSettingsModal(response.providers); // Updated to use new function
console.log('Providers loaded successfully'); console.log('Providers loaded successfully');
} }
@ -733,6 +742,411 @@ class DNSReconApp {
} }
} }
/**
* Build the enhanced settings modal with provider configuration and API keys
* @param {Object} providers - Provider information from backend
*/
buildSettingsModal(providers) {
this.buildProviderConfigSection(providers);
this.buildApiKeySection(providers);
this.updateSettingsCounts(providers);
}
/**
* Build the provider configuration section with enable/disable checkboxes
* @param {Object} providers - Provider information
*/
buildProviderConfigSection(providers) {
const providerConfigList = document.getElementById('provider-config-list');
if (!providerConfigList) return;
providerConfigList.innerHTML = '';
for (const [name, info] of Object.entries(providers)) {
const providerConfig = document.createElement('div');
providerConfig.className = 'provider-item';
const statusClass = info.enabled ? 'enabled' : 'disabled';
const statusIcon = info.enabled ? '✓' : '✗';
providerConfig.innerHTML = `
<div class="provider-header">
<div class="provider-name">${info.display_name}</div>
<div class="provider-status ${statusClass}">
${statusIcon} ${info.enabled ? 'Enabled' : 'Disabled'}
</div>
</div>
<div class="status-row">
<div class="status-label">
<label style="display: flex; align-items: center; gap: 0.5rem; cursor: pointer;">
<input type="checkbox"
data-provider="${name}"
class="provider-toggle"
${info.enabled ? 'checked' : ''}
style="appearance: none; width: 16px; height: 16px; border: 2px solid #555; background: #1a1a1a; cursor: pointer; position: relative;">
<span>Auto-process with this provider</span>
</label>
</div>
</div>
`;
providerConfigList.appendChild(providerConfig);
}
// Add checkbox styling and event handlers
this.setupProviderCheckboxes();
}
/**
* Setup provider checkbox styling and event handlers
*/
setupProviderCheckboxes() {
const checkboxes = document.querySelectorAll('.provider-toggle');
checkboxes.forEach(checkbox => {
// Apply existing checkbox styling
checkbox.style.cssText = `
appearance: none;
width: 16px;
height: 16px;
border: 2px solid #555;
background: #1a1a1a;
cursor: pointer;
position: relative;
border-radius: 3px;
transition: all 0.3s ease;
`;
// Update visual state
this.updateCheckboxAppearance(checkbox);
// Add change event handler
checkbox.addEventListener('change', (e) => {
this.updateCheckboxAppearance(e.target);
});
});
}
/**
* Add CSS for checkbox styling since we're using existing styles
*/
addCheckboxStyling() {
// Add CSS for the checkboxes to work with existing styles
const style = document.createElement('style');
style.textContent = `
.provider-toggle[data-checked="true"]::after {
content: '✓';
position: absolute;
top: -2px;
left: 2px;
color: #1a1a1a;
font-size: 12px;
font-weight: bold;
}
.provider-toggle:hover {
border-color: #00ff41;
}
.api-key-status-row {
transition: all 0.3s ease;
}
.provider-item {
margin-bottom: 1rem;
}
.provider-item:last-child {
margin-bottom: 0;
}
`;
document.head.appendChild(style);
}
/**
* Update checkbox appearance based on checked state
*/
updateCheckboxAppearance(checkbox) {
if (checkbox.checked) {
checkbox.style.background = '#00ff41';
checkbox.style.borderColor = '#00ff41';
checkbox.style.setProperty('content', '"✓"', 'important');
// Add checkmark via pseudo-element simulation
checkbox.setAttribute('data-checked', 'true');
} else {
checkbox.style.background = '#1a1a1a';
checkbox.style.borderColor = '#555';
checkbox.removeAttribute('data-checked');
}
}
/**
* Enhanced API key section builder - FIXED to always allow API key input
* @param {Object} providers - Provider information
*/
buildApiKeySection(providers) {
const apiKeyInputs = document.getElementById('api-key-inputs');
if (!apiKeyInputs) return;
apiKeyInputs.innerHTML = '';
let hasApiKeyProviders = false;
for (const [name, info] of Object.entries(providers)) {
if (info.requires_api_key) {
hasApiKeyProviders = true;
const inputGroup = document.createElement('div');
inputGroup.className = 'provider-item';
// Check if API key is set via backend (not clearable) or frontend (clearable)
const isBackendConfigured = info.api_key_source === 'backend';
if (info.api_key_configured && isBackendConfigured) {
// API key is configured via backend - show status only
inputGroup.innerHTML = `
<div class="provider-header">
<div class="provider-name">${info.display_name}</div>
<div class="provider-status enabled"> Backend Configured</div>
</div>
<div class="api-key-status-row" style="padding: 0.75rem; background: rgba(0, 255, 65, 0.1); border-radius: 4px; border: 1px solid rgba(0, 255, 65, 0.3);">
<div style="display: flex; justify-content: space-between; align-items: center;">
<div>
<div class="status-value">API Key Active</div>
<div class="status-label" style="font-size: 0.8rem;">
Configured via environment variable
</div>
</div>
</div>
</div>
`;
} else if (info.api_key_configured && !isBackendConfigured) {
// API key is configured via frontend - show status with clear option
inputGroup.innerHTML = `
<div class="provider-header">
<div class="provider-name">${info.display_name}</div>
<div class="provider-status enabled"> Web Configured</div>
</div>
<div class="api-key-status-row" style="padding: 0.75rem; background: rgba(0, 255, 65, 0.1); border-radius: 4px; border: 1px solid rgba(0, 255, 65, 0.3);">
<div style="display: flex; justify-content: space-between; align-items: center;">
<div>
<div class="status-value">API Key Active</div>
<div class="status-label" style="font-size: 0.8rem;">
Set via web interface (session-only)
</div>
</div>
<button class="clear-api-key-btn btn-secondary" data-provider="${name}" style="padding: 0.4rem 0.8rem; font-size: 0.8rem;">
<span class="btn-icon">[×]</span>
<span>Clear</span>
</button>
</div>
</div>
`;
} else {
// API key not configured - ALWAYS show input field
const statusClass = info.enabled ? 'enabled' : 'api-key-required';
const statusText = info.enabled ? '○ Ready for API Key' : '⚠️ API Key Required';
inputGroup.innerHTML = `
<div class="provider-header">
<div class="provider-name">${info.display_name}</div>
<div class="provider-status ${statusClass}">
${statusText}
</div>
</div>
<div class="input-group">
<label for="${name}-api-key">API Key</label>
<input type="password"
id="${name}-api-key"
data-provider="${name}"
placeholder="Enter ${info.display_name} API Key"
autocomplete="off">
<div class="apikey-help">
${info.api_key_help || `Provides enhanced ${info.display_name.toLowerCase()} data and context.`}
${!info.enabled ? ' Enable the provider above to use this API key.' : ''}
</div>
</div>
`;
}
apiKeyInputs.appendChild(inputGroup);
}
}
if (!hasApiKeyProviders) {
apiKeyInputs.innerHTML = `
<div class="status-row">
<div class="status-label">No providers require API keys</div>
<div class="status-value">All Active</div>
</div>
`;
}
// Setup clear button event handlers
this.setupApiKeyClearHandlers();
}
/**
* Setup API key clear button handlers
*/
setupApiKeyClearHandlers() {
document.querySelectorAll('.clear-api-key-btn').forEach(button => {
button.addEventListener('click', (e) => {
e.preventDefault();
const provider = e.currentTarget.dataset.provider;
this.clearSingleApiKey(provider, e.currentTarget);
});
});
}
/**
* Clear a single API key with immediate feedback
*/
async clearSingleApiKey(provider, buttonElement) {
try {
// Show immediate feedback
const originalContent = buttonElement.innerHTML;
buttonElement.innerHTML = '<span class="btn-icon">[...]</span><span>Clearing...</span>';
buttonElement.disabled = true;
const response = await this.apiCall('/api/config/api-keys', 'POST', { [provider]: '' });
if (response.success) {
// Find the parent container and update it
const providerContainer = buttonElement.closest('.provider-item');
const statusRow = providerContainer.querySelector('.api-key-status-row');
// Animate out the current status
statusRow.style.transition = 'all 0.3s ease';
statusRow.style.opacity = '0';
statusRow.style.transform = 'translateX(-10px)';
setTimeout(() => {
// Replace with input field
const providerName = buttonElement.dataset.provider;
const apiKeySection = this.elements.apiKeyInputs;
// Rebuild the API key section to reflect changes
this.loadProviders();
this.showSuccess(`API key for ${provider} has been cleared.`);
}, 300);
} else {
throw new Error(response.error || 'Failed to clear API key');
}
} catch (error) {
// Restore button on error
buttonElement.innerHTML = originalContent;
buttonElement.disabled = false;
this.showError(`Error clearing API key: ${error.message}`);
}
}
/**
* Update settings modal counts
*/
updateSettingsCounts(providers) {
const providerCount = Object.keys(providers).length;
const apiKeyCount = Object.values(providers).filter(p => p.requires_api_key).length;
const providerCountElement = document.getElementById('provider-count');
const apiKeyCountElement = document.getElementById('api-key-count');
if (providerCountElement) providerCountElement.textContent = providerCount;
if (apiKeyCountElement) apiKeyCountElement.textContent = apiKeyCount;
}
/**
* Enhanced save settings function
*/
async saveSettings() {
try {
const settings = {
apiKeys: {},
providerSettings: {}
};
// Collect API key inputs
const apiKeyInputs = document.querySelectorAll('#api-key-inputs input[type="password"]');
apiKeyInputs.forEach(input => {
const provider = input.dataset.provider;
const value = input.value.trim();
if (provider && value) {
settings.apiKeys[provider] = value;
}
});
// Collect provider enable/disable settings
const providerCheckboxes = document.querySelectorAll('.provider-toggle');
providerCheckboxes.forEach(checkbox => {
const provider = checkbox.dataset.provider;
if (provider) {
settings.providerSettings[provider] = {
enabled: checkbox.checked
};
}
});
// Save API keys if any
if (Object.keys(settings.apiKeys).length > 0) {
const apiKeyResponse = await this.apiCall('/api/config/api-keys', 'POST', settings.apiKeys);
if (!apiKeyResponse.success) {
throw new Error(apiKeyResponse.error || 'Failed to save API keys');
}
}
// Save provider settings if any
if (Object.keys(settings.providerSettings).length > 0) {
const providerResponse = await this.apiCall('/api/config/providers', 'POST', settings.providerSettings);
if (!providerResponse.success) {
throw new Error(providerResponse.error || 'Failed to save provider settings');
}
}
this.showSuccess('Settings saved successfully');
this.hideSettingsModal();
// Reload providers to reflect changes
this.loadProviders();
} catch (error) {
this.showError(`Error saving settings: ${error.message}`);
}
}
/**
* Reset settings to defaults
*/
async resetSettings() {
try {
// Clear all API key inputs
const apiKeyInputs = document.querySelectorAll('#api-key-inputs input[type="password"]');
apiKeyInputs.forEach(input => {
input.value = '';
});
// Reset all provider checkboxes to enabled (default)
const providerCheckboxes = document.querySelectorAll('.provider-toggle');
providerCheckboxes.forEach(checkbox => {
checkbox.checked = true;
this.updateCheckboxAppearance(checkbox);
});
// Reset recursion depth to default
const depthSelect = document.getElementById('max-depth');
if (depthSelect) {
depthSelect.value = '2';
}
this.showInfo('Settings reset to defaults');
} catch (error) {
this.showError(`Error resetting settings: ${error.message}`);
}
}
/** /**
* Update provider display * Update provider display
* @param {Object} providers - Provider information * @param {Object} providers - Provider information
@ -1501,33 +1915,6 @@ class DNSReconApp {
} }
} }
/**
* Copy text to clipboard with user feedback
*/
async copyToClipboard(text) {
try {
await navigator.clipboard.writeText(text);
this.showMessage('Copied to clipboard', 'success');
} catch (err) {
// Fallback for older browsers
const textArea = document.createElement('textarea');
textArea.value = text;
textArea.style.position = 'fixed';
textArea.style.opacity = '0';
document.body.appendChild(textArea);
textArea.select();
try {
document.execCommand('copy');
this.showMessage('Copied to clipboard', 'success');
} catch (fallbackErr) {
this.showMessage('Failed to copy text', 'error');
}
document.body.removeChild(textArea);
}
}
/** /**
* Enhanced keyboard navigation for modals * Enhanced keyboard navigation for modals
*/ */
@ -1690,40 +2077,7 @@ class DNSReconApp {
}); });
} }
/**
* Check if graph data has changed
* @param {Object} graphData - New graph data
* @returns {boolean} True if data has changed
*/
hasGraphChanged(graphData) {
// Simple check based on node and edge counts and timestamps
const currentStats = this.graphManager.getStatistics();
const newNodeCount = graphData.nodes ? graphData.nodes.length : 0;
const newEdgeCount = graphData.edges ? graphData.edges.length : 0;
// FIXED: Always update if we currently have no data (ensures placeholder is handled correctly)
if (currentStats.nodeCount === 0 && currentStats.edgeCount === 0) {
return true;
}
// Check if counts changed
const countsChanged = currentStats.nodeCount !== newNodeCount || currentStats.edgeCount !== newEdgeCount;
// Also check if we have new timestamp data
const hasNewTimestamp = graphData.statistics &&
graphData.statistics.last_modified &&
graphData.statistics.last_modified !== this.lastGraphTimestamp;
if (hasNewTimestamp) {
this.lastGraphTimestamp = graphData.statistics.last_modified;
}
const changed = countsChanged || hasNewTimestamp;
console.log(`Graph change check: Current(${currentStats.nodeCount}n, ${currentStats.edgeCount}e) vs New(${newNodeCount}n, ${newEdgeCount}e) = ${changed}`);
return changed;
}
/** /**
* Make API call to server * Make API call to server
@ -1823,15 +2177,6 @@ class DNSReconApp {
return statusMap[status] || status; return statusMap[status] || status;
} }
/**
* Format label for display
* @param {string} label - Raw label
* @returns {string} Formatted label
*/
formatLabel(label) {
return label.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase());
}
/** /**
* Format value for display * Format value for display
* @param {*} value - Raw value * @param {*} value - Raw value
@ -1955,73 +2300,7 @@ class DNSReconApp {
return colors[type] || colors.info; return colors[type] || colors.info;
} }
/**
* Build the API key modal dynamically
* @param {Object} providers - Provider information
*/
buildApiKeyModal(providers) {
if (!this.elements.apiKeyInputs) return;
this.elements.apiKeyInputs.innerHTML = ''; // Clear existing inputs
let hasApiKeyProviders = false;
for (const [name, info] of Object.entries(providers)) {
if (info.requires_api_key) {
hasApiKeyProviders = true;
const inputGroup = document.createElement('div');
inputGroup.className = 'apikey-section';
if (info.enabled) {
// If the API key is set and the provider is enabled
inputGroup.innerHTML = `
<label for="${name}-api-key">${info.display_name} API Key</label>
<div class="api-key-set-message">
<span class="api-key-set-text">API Key is set</span>
<button class="clear-api-key-btn" data-provider="${name}">Clear</button>
</div>
<p class="apikey-help">Provides infrastructure context and service information.</p>
`;
} else {
// If the API key is not set
inputGroup.innerHTML = `
<label for="${name}-api-key">${info.display_name} API Key</label>
<input type="password" id="${name}-api-key" data-provider="${name}" placeholder="Enter ${info.display_name} API Key">
<p class="apikey-help">Provides infrastructure context and service information.</p>
`;
}
this.elements.apiKeyInputs.appendChild(inputGroup);
}
}
// Add event listeners for the new clear buttons
this.elements.apiKeyInputs.querySelectorAll('.clear-api-key-btn').forEach(button => {
button.addEventListener('click', (e) => {
const provider = e.target.dataset.provider;
this.clearApiKey(provider);
});
});
if (!hasApiKeyProviders) {
this.elements.apiKeyInputs.innerHTML = '<p>No providers require API keys.</p>';
}
}
/**
* Clear an API key for a specific provider
* @param {string} provider The name of the provider to clear the API key for
*/
async clearApiKey(provider) {
try {
const response = await this.apiCall('/api/config/api-keys', 'POST', { [provider]: '' });
if (response.success) {
this.showSuccess(`API key for ${provider} has been cleared.`);
this.loadProviders(); // This will rebuild the modal with the updated state
} else {
throw new Error(response.error || 'Failed to clear API key');
}
} catch (error) {
this.showError(`Error clearing API key: ${error.message}`);
}
}
} }
// Add CSS animations for message toasts // Add CSS animations for message toasts

View File

@ -1,5 +1,6 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
@ -7,8 +8,11 @@
<link rel="stylesheet" href="{{ url_for('static', filename='css/main.css') }}"> <link rel="stylesheet" href="{{ url_for('static', filename='css/main.css') }}">
<script src="https://cdnjs.cloudflare.com/ajax/libs/vis/4.21.0/vis.min.js"></script> <script src="https://cdnjs.cloudflare.com/ajax/libs/vis/4.21.0/vis.min.js"></script>
<link href="https://cdnjs.cloudflare.com/ajax/libs/vis/4.21.0/vis.min.css" rel="stylesheet" type="text/css"> <link href="https://cdnjs.cloudflare.com/ajax/libs/vis/4.21.0/vis.min.css" rel="stylesheet" type="text/css">
<link href="https://fonts.googleapis.com/css2?family=Roboto+Mono:wght@300;400;500;700&family=Special+Elite&display=swap" rel="stylesheet"> <link
href="https://fonts.googleapis.com/css2?family=Roboto+Mono:wght@300;400;500;700&family=Special+Elite&display=swap"
rel="stylesheet">
</head> </head>
<body> <body>
<div class="container"> <div class="container">
<header class="header"> <header class="header">
@ -95,11 +99,13 @@
</div> </div>
<div class="progress-placeholder"> <div class="progress-placeholder">
<span class="status-label"> <span class="status-label">
⚠️ <strong>Important:</strong> Scanning large public services (e.g., Google, Cloudflare, AWS) is ⚠️ <strong>Important:</strong> Scanning large public services (e.g., Google, Cloudflare,
AWS) is
<strong>discouraged</strong> due to rate limits (e.g., crt.sh). <strong>discouraged</strong> due to rate limits (e.g., crt.sh).
<br><br> <br><br>
Our task scheduler operates on a <strong>priority-based queue</strong>: Our task scheduler operates on a <strong>priority-based queue</strong>:
Short, targeted tasks like DNS are processed first, while resource-intensive requests (e.g., crt.sh) Short, targeted tasks like DNS are processed first, while resource-intensive requests (e.g.,
crt.sh)
are <strong>automatically deprioritized</strong> and may be processed later. are <strong>automatically deprioritized</strong> and may be processed later.
</span> </span>
</div> </div>
@ -116,7 +122,8 @@
<div class="placeholder-content"> <div class="placeholder-content">
<div class="placeholder-icon">[○]</div> <div class="placeholder-icon">[○]</div>
<div class="placeholder-text">Infrastructure map will appear here</div> <div class="placeholder-text">Infrastructure map will appear here</div>
<div class="placeholder-subtext">Start a reconnaissance scan to visualize relationships</div> <div class="placeholder-subtext">Start a reconnaissance scan to visualize relationships
</div>
</div> </div>
</div> </div>
</div> </div>
@ -159,7 +166,7 @@
</div> </div>
<div id="provider-list" class="provider-list"> <div id="provider-list" class="provider-list">
</div> </div>
</section> </section>
</main> </main>
@ -181,61 +188,93 @@
</div> </div>
<div class="modal-body"> <div class="modal-body">
<div id="modal-details"> <div id="modal-details">
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<!-- Replace the existing settings modal section in index.html -->
<div id="settings-modal" class="modal"> <div id="settings-modal" class="modal">
<div class="modal-content"> <div class="modal-content">
<div class="modal-header"> <div class="modal-header">
<h3>Settings</h3> <h3>Scanner Configuration</h3>
<button id="settings-modal-close" class="modal-close">[×]</button> <button id="settings-modal-close" class="modal-close">[×]</button>
</div> </div>
<div class="modal-body"> <div class="modal-body">
<p class="modal-description"> <div class="modal-details">
Configure scan settings and API keys. Keys are stored in memory for the current session only. <!-- Scan Settings Section -->
Only provide API-keys you dont use for anything else. Don´t enter an API-key if you don´t trust me (best practice would that you don´t). <section class="modal-section">
</p> <details open>
<br> <summary>
<div class="input-group"> <span>⚙️ Scan Settings</span>
<label for="max-depth">Recursion Depth</label> </summary>
<select id="max-depth"> <div class="modal-section-content">
<option value="1">Depth 1 - Direct relationships</option> <div class="input-group">
<option value="2" selected>Depth 2 - Recommended</option> <label for="max-depth">Recursion Depth</label>
<option value="3">Depth 3 - Extended analysis</option> <select id="max-depth">
<option value="4">Depth 4 - Deep reconnaissance</option> <option value="1">Depth 1 - Direct relationships</option>
<option value="5">Depth 5 - Maximum depth</option> <option value="2" selected>Depth 2 - Recommended</option>
</select> <option value="3">Depth 3 - Extended analysis</option>
</div> <option value="4">Depth 4 - Deep reconnaissance</option>
<div id="api-key-inputs"> <option value="5">Depth 5 - Maximum depth</option>
</select>
</div>
</div>
</details>
</section>
<!-- Provider Configuration Section -->
<section class="modal-section">
<details open>
<summary>
<span>🔧 Provider Configuration</span>
<span class="merge-badge" id="provider-count">0</span>
</summary>
<div class="modal-section-content">
<div id="provider-config-list">
<!-- Dynamically populated -->
</div>
</div>
</details>
</section>
<!-- API Keys Section -->
<section class="modal-section">
<details>
<summary>
<span>🔑 API Keys</span>
<span class="merge-badge" id="api-key-count">0</span>
</summary>
<div class="modal-section-content">
<p class="placeholder-subtext" style="margin-bottom: 1rem;">
⚠️ API keys are stored in memory for the current session only.
Only provide API keys you don't use for anything else.
</p>
<div id="api-key-inputs">
<!-- Dynamically populated -->
</div>
</div>
</details>
</section>
<!-- Action Buttons -->
<div class="button-group" style="margin-top: 1.5rem;">
<button id="save-settings" class="btn btn-primary">
<span class="btn-icon">[SAVE]</span>
<span>Save Configuration</span>
</button>
<button id="reset-settings" class="btn btn-secondary">
<span class="btn-icon">[RESET]</span>
<span>Reset to Defaults</span>
</button>
</div> </div>
<div class="button-group" style="flex-direction: row; justify-content: flex-end;">
<button id="reset-api-keys" class="btn btn-secondary">
<span>Reset</span>
</button>
<button id="save-api-keys" class="btn btn-primary">
<span>Save API-Keys</span>
</button>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<script>
function copyToClipboard(elementId) {
const element = document.getElementById(elementId);
const textToCopy = element.innerText;
navigator.clipboard.writeText(textToCopy).then(() => {
// Optional: Show a success message
console.log('Copied to clipboard');
}).catch(err => {
console.error('Failed to copy: ', err);
});
}
</script>
<script src="{{ url_for('static', filename='js/graph.js') }}"></script> <script src="{{ url_for('static', filename='js/graph.js') }}"></script>
<script src="{{ url_for('static', filename='js/main.js') }}"></script> <script src="{{ url_for('static', filename='js/main.js') }}"></script>
</body> </body>
</html> </html>