# Complete Enhancement Package - Major Feature Update Comprehensive enhancement package for docker-ddns-server including security features, modern authentication, UI/UX improvements, and production-ready deployment features. ## 🔒 Security & Authentication ### IP Blocking System - Implemented automatic IP blocking after 3 failed authentication attempts within 72 hours - Added 7-day block duration with automatic expiration - Created `blocked_ips` database table for tracking blocked addresses - Added automatic cleanup of expired blocks - Implemented manual IP unblock capability via security dashboard ### Failed Authentication Logging - Added comprehensive failed authentication logging system - Created `failed_auths` database table storing IP, timestamp, username, and password - Implemented threat intelligence features for password pattern analysis - Added automatic cleanup of old authentication records - Logs intentionally include passwords for single-user security analysis ### Session-Based Authentication - Replaced HTTP Basic Auth with modern session-based authentication for admin panel - Integrated gorilla/sessions library for secure session management - Added configurable session secrets via `DDNS_SESSION_SECRET` environment variable - Implemented "Remember Me" functionality with 30-day session duration - Added proper session destruction on logout - Session cookies configured with HttpOnly, Secure, and SameSite attributes - Maintained HTTP Basic Auth for DynDNS API endpoints (device compatibility) ### HTTPS Enforcement - Added intelligent HTTPS detection via multiple header checks - Implemented automatic HTTPS redirect for admin panel when available - Graceful HTTP fallback when HTTPS unavailable - Supports reverse proxy configurations (nginx, Caddy, Traefik) - Detects SSL via X-Forwarded-Proto, X-Forwarded-Ssl, X-Url-Scheme headers - API endpoints remain HTTP-compatible for device support ## 🎨 UI/UX Enhancements ### Authentication UI - Created modern login page with gradient background and clean design - Added HTTPS security indicator (✓ green / ⚠ yellow) - Implemented auto-focus on username field - Added clear error messages for failed login attempts - Created logout confirmation page with redirect options - Removed browser authentication dialog popups ### Navigation & Layout - Changed admin panel URL from `/admin` to `/@` for uniqueness - Updated navigation with unicode icons (🏠 Dashboard, 🔒 Security, ⏏️ Logout) - Added tooltips to all navigation icons - Implemented sticky header that remains visible on scroll - Enhanced responsive design for mobile/tablet access ### Logo Support - Added automatic logo detection and display - Supports PNG, WebP, and SVG formats - Checks `/static/icons/` for logo files - Graceful fallback to text title if no logo found - Maintains aspect ratio and responsive sizing ### Security Dashboard - Created comprehensive security overview page at `/@/security` - Added statistics cards showing active blocks, failed attempts, and total blocks - Implemented recent failed attempts table with sortable columns - Added password reveal/hide functionality with confirmation prompts - Created detailed blocked IPs management page with unblock capability - Created detailed failed authentication logs page with full history - Added visual indicators for security status ## 📊 Data Management ### Data Consistency & Normalization - Implemented automatic lowercase conversion for all usernames and hostnames - Prevents case-sensitivity issues in DNS lookups and authentication - Ensures consistent data storage and retrieval - Handles mixed-case legacy data gracefully ### Automatic Migration - Added on-the-fly migration system for legacy uppercase entries - Migration triggers automatically on first `/@/hosts` page visit - Handles hostname conflicts by appending sequential numbers - Provides detailed migration report in UI showing all changes - Non-destructive migration preserves all host data - One-time execution with persistent migration status tracking ### Validation Updates - Reduced minimum hostname length to 1 character (allows single-letter subdomains) - Reduced minimum username length to 1 character - Reduced minimum password length to 6 characters - Maintained security while improving flexibility ### Username Uniqueness - Removed uniqueness constraint on usernames - Allows multiple hosts to share the same username - Supports different passwords for same username across hosts - Enables more flexible credential management strategies ## 🛡️ Middleware & Request Handling ### IP Blocker Middleware - Created IPBlockerMiddleware to check requests against blocked IPs - Automatic redirect to 127.0.0.1 for blocked addresses - Lightweight performance impact with database lookup - Positioned early in middleware chain for efficiency ### Session Authentication Middleware - Created SessionAuthMiddleware for admin panel protection - Skips authentication check for /login and /logout routes - Redirects unauthenticated users to login page - Validates session integrity on every request - Compatible with reverse proxy configurations ### HTTPS Redirect Middleware - Created HTTPSRedirectMiddleware for admin panel security - Intelligent detection of HTTPS availability - Skips redirect for API endpoints - Handles X-Forwarded-* headers from reverse proxies - Graceful operation when HTTPS unavailable ## 🗄️ Database & Models ### New Tables - Added `failed_auths` table for authentication logging - Added `blocked_ips` table for IP block tracking - Proper foreign key relationships and indexes - Automatic timestamps on all records ### Cleanup Functions - Implemented automatic cleanup of expired IP blocks - Implemented automatic cleanup of old authentication logs - Configurable retention periods - Background cleanup execution ## 🔧 Technical Improvements ### Dependencies - Added `github.com/gorilla/sessions@v1.2.2` for session management - Updated go.mod with proper version constraints - Maintained compatibility with existing dependencies ### Handler Architecture - Separated security logic into dedicated handler files - Created `security.go` for blocking logic and logging - Created `security_dashboard.go` for UI handlers - Created `auth.go` for login/logout and session management - Created `session.go` for session store implementation - Improved code organization and maintainability ### Main Application - Updated routing to support session-based authentication - Added session initialization on startup - Configured route groups for admin panel and API - Middleware ordering optimized for performance and security ## 🐳 Docker & CI/CD ### Multi-Platform Builds & Automated Releases - Created GitHub Actions workflow (`BuildEmAll.yml`) for automated Docker builds - Supports linux/amd64, linux/386, linux/arm/v7, and linux/arm64 platforms - Automatic builds on push to master with dyndns/ directory changes - Intelligent version tagging system: - Extracts version from commit message (e.g., "v1.2.3 Feature description") - Auto-increments patch version from latest git tag - Falls back to date-based versioning (vYY.MM.DD-HHMM) if no tags exist - Tags images with both `:latest` and semantic version tags (`:vX.Y.Z`) - Automatic GitHub release creation with each build - Release includes Docker image reference and commit message as notes - Publishes to Docker Hub (w3kllc/ddns) - Cross-platform compatibility for ARM devices (Raspberry Pi, etc.) - Workflow can be triggered manually via GitHub Actions UI ### Deployment - Enhanced docker-compose.yml example with all new features - Added documentation for environment variable configuration - Included reverse proxy configuration examples - Added security best practices for production deployment - Semantic versioning with automatic release management ## 📝 Documentation ### README Enhancements - Added comprehensive Security Features section - Added Environment Variables reference with descriptions - Added Admin Panel Access documentation - Added Data Consistency & Migration guide - Added API Endpoints documentation - Added UI/UX Enhancements overview - Added Reverse Proxy Configuration examples - Added Docker Configuration best practices - Added CI/CD & Multi-Platform Support details with versioning strategy - Added Semantic Versioning documentation - Added GitHub Release automation details - Added Security Best Practices recommendations - Added Threat Intelligence rationale - Added Migration Guide from original project - Added Troubleshooting section - Added API Reference documentation - Added Roadmap for future features - Updated Credits section - Added Support and Community links ## 🔄 Backward Compatibility ### Maintained Features - DynDNS API endpoints remain unchanged (/update, /nic/update, etc.) - HTTP Basic Auth still supported for API (device compatibility) - Existing host configurations continue working without changes - Database schema additions are non-breaking - All original functionality preserved ### Breaking Changes - Admin panel URL changed from `/admin` to `/@` (intentional, more unique) - Admin authentication method changed (sessions vs basic auth) - Requires `DDNS_SESSION_SECRET` environment variable for session security ## ⚡ Performance Considerations - IP blocker checks are optimized with database indexing - Session validation cached in memory - Automatic cleanup runs asynchronously - Minimal overhead on API endpoint performance - Efficient middleware ordering ## 🎯 Testing Considerations Recommended testing areas: - Login/logout flow with and without HTTPS - IP blocking after 3 failed attempts - Session persistence with remember me - API endpoint authentication (device compatibility) - HTTPS redirect with reverse proxy headers - Password reveal/hide in security dashboard - Hostname migration for legacy uppercase entries - Multi-platform Docker image functionality --- **Total Changes:** - **21 files modified** - **20 new files created** - **~2000+ lines of code added** - **100+ hours of development time** **Compatibility:** - ✅ Backward compatible for DynDNS API - ⚠️ Admin panel URL changed (bookmark update needed) - ✅ All existing hosts continue working - ✅ Database schema additions are additive **Credits:** - Original project: dprandzioch/docker-ddns - Web UI Fork: benjaminbear/docker-ddns-server - Enhanced fork: w3K-one/docker-ddns-server - Major enhancements and security features added This represents a significant enhancement to the original project while maintaining the core DynDNS functionality and adding modern security, authentication, and user experience improvements suitable for production deployment.
434 lines
15 KiB
HTML
434 lines
15 KiB
HTML
{{define "content"}}
|
|
<div class="container marketing">
|
|
<h3 class="text-center mb-4">Security Dashboard</h3>
|
|
|
|
<!-- Summary Cards -->
|
|
<div class="row mb-4">
|
|
<div class="col-md-4">
|
|
<div class="card text-white bg-danger mb-3">
|
|
<div class="card-header">Active Blocks</div>
|
|
<div class="card-body">
|
|
<h2 class="card-title">{{.activeBlocks}}</h2>
|
|
<p class="card-text">IP addresses currently blocked</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="col-md-4">
|
|
<div class="card text-white bg-warning mb-3">
|
|
<div class="card-header">Failed Attempts</div>
|
|
<div class="card-body">
|
|
<h2 class="card-title">{{len .failedAuths}}</h2>
|
|
<p class="card-text">Recent failed login attempts</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="col-md-4">
|
|
<div class="card text-white bg-info mb-3">
|
|
<div class="card-header">Total Blocks</div>
|
|
<div class="card-body">
|
|
<h2 class="card-title">{{len .blockedIPs}}</h2>
|
|
<p class="card-text">Total IP blocks (active + expired)</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Recently Blocked IPs -->
|
|
<h4 class="mb-3">Recently Blocked IPs</h4>
|
|
<table class="table table-striped">
|
|
<thead>
|
|
<tr>
|
|
<th>IP Address</th>
|
|
<th>Blocked At</th>
|
|
<th>Blocked Until</th>
|
|
<th>Failures</th>
|
|
<th>Status</th>
|
|
<th>Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{{range .blockedIPs}}
|
|
{{if .IsBlocked}}
|
|
<tr>
|
|
<td><code>{{.IPAddress}}</code></td>
|
|
<td>{{.BlockedAt.Format "01/02/2006 15:04"}}</td>
|
|
<td>{{if .IsPermanent}}Permanent{{else}}{{.BlockedUntil.Format "01/02/2006 15:04"}}{{end}}</td>
|
|
<td><span class="badge badge-danger">{{.FailureCount}}</span></td>
|
|
<td><span class="badge badge-danger">Active</span></td>
|
|
<td>
|
|
<button class="btn btn-sm btn-warning unblock-btn" data-ip="{{.IPAddress}}">Unblock</button>
|
|
</td>
|
|
</tr>
|
|
{{end}}
|
|
{{end}}
|
|
</tbody>
|
|
</table>
|
|
|
|
<!-- Recent Failed Authentication Attempts -->
|
|
<h4 class="mb-3 mt-4">Recent Failed Authentication Attempts</h4>
|
|
<div class="alert alert-warning" role="alert">
|
|
<strong>⚠️ Security Warning:</strong> This system logs attempted passwords for security analysis. Ensure database access is strictly controlled.
|
|
</div>
|
|
<div class="alert alert-info" role="alert">
|
|
<strong>📌 IP Blocking Policy:</strong> Only failed attempts to the admin panel (<code>/@/*</code>) count toward IP blocking.
|
|
API endpoint failures are logged but do NOT trigger automatic blocks.
|
|
</div>
|
|
|
|
<style>
|
|
/* Alternating yellow shades for API rows */
|
|
.table-warning-light {
|
|
background-color: #fff9e6 !important; /* Lighter yellow */
|
|
}
|
|
|
|
.table-warning {
|
|
background-color: #fff3cd !important; /* Standard yellow */
|
|
}
|
|
|
|
/* Tooltip container for positioning */
|
|
.tooltip-container {
|
|
position: relative;
|
|
display: inline-block;
|
|
}
|
|
|
|
/* Speech bubble tooltip */
|
|
.speech-tooltip {
|
|
position: absolute;
|
|
bottom: 100%;
|
|
left: 50%;
|
|
transform: translateX(-50%);
|
|
margin-bottom: 10px;
|
|
padding: 8px 12px;
|
|
background: #2c3e50;
|
|
color: #fff;
|
|
border-radius: 6px;
|
|
font-size: 13px;
|
|
font-family: 'Courier New', monospace;
|
|
z-index: 1000;
|
|
opacity: 0;
|
|
visibility: hidden;
|
|
transition: opacity 0.2s, visibility 0.2s;
|
|
box-shadow: 0 2px 8px rgba(0,0,0,0.3);
|
|
}
|
|
|
|
/* Password tooltips: single line, auto-width */
|
|
.pwd-btn + .speech-tooltip {
|
|
white-space: nowrap;
|
|
max-width: 90vw; /* Don't exceed viewport */
|
|
}
|
|
|
|
/* User Agent tooltips: multi-line, 50% table width */
|
|
.ua-btn + .speech-tooltip,
|
|
.ua-btn .speech-tooltip {
|
|
white-space: normal;
|
|
max-width: 50%; /* 50% of container width */
|
|
min-width: 200px;
|
|
word-wrap: break-word;
|
|
text-align: left;
|
|
}
|
|
|
|
/* Arrow pointing down */
|
|
.speech-tooltip::after {
|
|
content: '';
|
|
position: absolute;
|
|
top: 100%;
|
|
left: 50%;
|
|
transform: translateX(-50%);
|
|
border: 6px solid transparent;
|
|
border-top-color: #2c3e50;
|
|
}
|
|
|
|
/* Show tooltip when active */
|
|
.speech-tooltip.active {
|
|
opacity: 1;
|
|
visibility: visible;
|
|
}
|
|
|
|
/* Clickable tooltip styling with cursor */
|
|
.tooltip-btn {
|
|
cursor: pointer;
|
|
transition: all 0.2s;
|
|
border: none;
|
|
background: transparent;
|
|
padding: 2px 6px;
|
|
border-radius: 3px;
|
|
position: relative;
|
|
}
|
|
|
|
.tooltip-btn:hover {
|
|
background-color: rgba(0,0,0,0.05);
|
|
}
|
|
|
|
.tooltip-btn.active {
|
|
background-color: #ffc107 !important;
|
|
}
|
|
|
|
/* User Agent button - make it look clickable */
|
|
.ua-btn {
|
|
cursor: pointer;
|
|
transition: background-color 0.2s;
|
|
}
|
|
|
|
.ua-btn:hover {
|
|
background-color: rgba(0,0,0,0.03);
|
|
}
|
|
|
|
/* Copy feedback animation */
|
|
.copy-feedback {
|
|
position: absolute;
|
|
top: -25px;
|
|
left: 50%;
|
|
transform: translateX(-50%);
|
|
background: #28a745;
|
|
color: white;
|
|
padding: 4px 8px;
|
|
border-radius: 4px;
|
|
font-size: 11px;
|
|
white-space: nowrap;
|
|
z-index: 2000;
|
|
animation: fadeInOut 1.5s ease-in-out;
|
|
pointer-events: none;
|
|
}
|
|
|
|
@keyframes fadeInOut {
|
|
0% { opacity: 0; transform: translateX(-50%) translateY(5px); }
|
|
20% { opacity: 1; transform: translateX(-50%) translateY(0); }
|
|
80% { opacity: 1; transform: translateX(-50%) translateY(0); }
|
|
100% { opacity: 0; transform: translateX(-50%) translateY(-5px); }
|
|
}
|
|
|
|
/* Truncated text styling */
|
|
.truncated {
|
|
/* No underline - clean look */
|
|
}
|
|
</style>
|
|
|
|
<table class="table table-striped" style="font-size: 14px">
|
|
<thead>
|
|
<tr>
|
|
<th>Timestamp</th>
|
|
<th>IP Address</th>
|
|
<th>Username</th>
|
|
<th>Password</th>
|
|
<th>Path</th>
|
|
<th>User Agent</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{{range $index, $auth := .failedAuths}}
|
|
{{$isAPI := not (hasPrefix $auth.Path "/@/")}}
|
|
{{$rowClass := ""}}
|
|
{{if $isAPI}}
|
|
{{if eq (mod $index 2) 0}}
|
|
{{$rowClass = "table-warning"}}
|
|
{{else}}
|
|
{{$rowClass = "table-warning-light"}}
|
|
{{end}}
|
|
{{end}}
|
|
<tr class="{{$rowClass}}">
|
|
<td>{{$auth.Timestamp.Format "01/02/2006 15:04:05"}}</td>
|
|
<td><code>{{$auth.IPAddress}}</code></td>
|
|
<td>{{if $auth.Username}}{{$auth.Username}}{{else}}<em>none</em>{{end}}</td>
|
|
<td>
|
|
{{if $auth.Password}}
|
|
<div class="tooltip-container">
|
|
<button class="btn btn-sm btn-outline-secondary tooltip-btn pwd-btn">👁</button>
|
|
<div class="speech-tooltip">
|
|
<code style="color: #ffc107; font-weight: bold;">{{$auth.Password}}</code>
|
|
</div>
|
|
</div>
|
|
{{else}}
|
|
<em>none</em>
|
|
{{end}}
|
|
</td>
|
|
<td>
|
|
<code>{{$auth.Path}}</code>
|
|
{{if hasPrefix $auth.Path "/@/"}}
|
|
<span class="badge badge-danger ml-1">Admin</span>
|
|
{{else}}
|
|
<span class="badge badge-secondary ml-1">API</span>
|
|
{{end}}
|
|
</td>
|
|
<td>
|
|
{{if $auth.UserAgent}}
|
|
{{$ua := $auth.UserAgent}}
|
|
{{if gt (len $ua) 10}}
|
|
<div class="tooltip-container">
|
|
<button class="tooltip-btn ua-btn" title="Click to copy">
|
|
<span class="truncated">{{slice $ua 0 10}}...</span>
|
|
</button>
|
|
<div class="speech-tooltip">
|
|
<span style="color: #fff;">{{$ua}}</span>
|
|
</div>
|
|
</div>
|
|
{{else}}
|
|
<span class="ua-btn" style="cursor: pointer;" title="Click to copy">{{$ua}}</span>
|
|
{{end}}
|
|
{{else}}
|
|
<em>none</em>
|
|
{{end}}
|
|
</td>
|
|
</tr>
|
|
{{end}}
|
|
</tbody>
|
|
</table>
|
|
|
|
<div class="text-center mt-4">
|
|
<a href="/@/security/blocked-ips" class="btn btn-primary">View All Blocked IPs</a>
|
|
<a href="/@/security/failed-auths" class="btn btn-secondary">View All Failed Attempts</a>
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
$(document).ready(function() {
|
|
console.log('Security dashboard JavaScript loaded');
|
|
|
|
// Helper function to copy text to clipboard
|
|
function copyToClipboard(text, button) {
|
|
// Modern clipboard API
|
|
if (navigator.clipboard && navigator.clipboard.writeText) {
|
|
navigator.clipboard.writeText(text).then(function() {
|
|
showCopyFeedback(button, 'Copied!');
|
|
}).catch(function(err) {
|
|
console.error('Failed to copy:', err);
|
|
fallbackCopy(text, button);
|
|
});
|
|
} else {
|
|
// Fallback for older browsers
|
|
fallbackCopy(text, button);
|
|
}
|
|
}
|
|
|
|
// Fallback copy method
|
|
function fallbackCopy(text, button) {
|
|
var textArea = document.createElement('textarea');
|
|
textArea.value = text;
|
|
textArea.style.position = 'fixed';
|
|
textArea.style.left = '-999999px';
|
|
document.body.appendChild(textArea);
|
|
textArea.select();
|
|
try {
|
|
document.execCommand('copy');
|
|
showCopyFeedback(button, 'Copied!');
|
|
} catch (err) {
|
|
console.error('Fallback copy failed:', err);
|
|
showCopyFeedback(button, 'Copy failed');
|
|
}
|
|
document.body.removeChild(textArea);
|
|
}
|
|
|
|
// Show copy feedback animation
|
|
function showCopyFeedback(button, message) {
|
|
var feedback = $('<div class="copy-feedback">' + message + '</div>');
|
|
$(button).closest('.tooltip-container, td').append(feedback);
|
|
setTimeout(function() {
|
|
feedback.remove();
|
|
}, 1500);
|
|
}
|
|
|
|
// Unblock IP button handler
|
|
$(document).on('click', '.unblock-btn', function(e) {
|
|
e.preventDefault();
|
|
var button = $(this);
|
|
var ip = button.attr('data-ip');
|
|
|
|
console.log('Unblock button clicked for IP:', ip);
|
|
|
|
if (confirm('Are you sure you want to unblock IP: ' + ip + '?')) {
|
|
$.ajax({
|
|
url: '/@/security/unblock/' + encodeURIComponent(ip),
|
|
type: 'POST',
|
|
success: function(result) {
|
|
console.log('Unblock success:', result);
|
|
alert('IP ' + ip + ' has been unblocked successfully!');
|
|
location.reload();
|
|
},
|
|
error: function(xhr, status, error) {
|
|
console.error('Unblock error:', xhr.responseText);
|
|
alert('Error unblocking IP: ' + (xhr.responseText || error));
|
|
}
|
|
});
|
|
}
|
|
});
|
|
|
|
// Password tooltips: Click to toggle AND copy
|
|
$(document).on('click', '.pwd-btn', function(e) {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
|
|
var button = $(this);
|
|
var tooltip = button.siblings('.speech-tooltip');
|
|
var password = button.closest('.tooltip-container').find('.speech-tooltip code').text();
|
|
|
|
// Copy password to clipboard
|
|
copyToClipboard(password, button);
|
|
|
|
// Close all other tooltips first
|
|
$('.speech-tooltip').removeClass('active');
|
|
$('.tooltip-btn').removeClass('active');
|
|
|
|
// Toggle this one
|
|
if (tooltip.hasClass('active')) {
|
|
tooltip.removeClass('active');
|
|
button.removeClass('active');
|
|
} else {
|
|
tooltip.addClass('active');
|
|
button.addClass('active');
|
|
}
|
|
});
|
|
|
|
// User Agent tooltips: Hover to show, Click to copy
|
|
$(document).on('mouseenter', '.ua-btn', function() {
|
|
var tooltip = $(this).siblings('.speech-tooltip');
|
|
tooltip.addClass('active');
|
|
});
|
|
|
|
$(document).on('mouseleave', '.ua-btn', function() {
|
|
var tooltip = $(this).siblings('.speech-tooltip');
|
|
// Small delay to allow moving mouse to tooltip
|
|
setTimeout(function() {
|
|
if (!tooltip.is(':hover')) {
|
|
tooltip.removeClass('active');
|
|
}
|
|
}, 100);
|
|
});
|
|
|
|
// Click on User Agent to copy
|
|
$(document).on('click', '.ua-btn', function(e) {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
|
|
var button = $(this);
|
|
var userAgent = button.closest('.tooltip-container').find('.speech-tooltip span').text();
|
|
|
|
// If there's no tooltip (short UA), get text directly
|
|
if (!userAgent) {
|
|
userAgent = button.text().trim();
|
|
}
|
|
|
|
copyToClipboard(userAgent, button);
|
|
});
|
|
|
|
// Keep tooltip open if hovering over it
|
|
$(document).on('mouseenter', '.ua-btn + .speech-tooltip', function() {
|
|
$(this).addClass('active');
|
|
});
|
|
|
|
$(document).on('mouseleave', '.ua-btn + .speech-tooltip', function() {
|
|
$(this).removeClass('active');
|
|
});
|
|
|
|
// Close password tooltips when clicking anywhere else on page
|
|
$(document).on('click', function(e) {
|
|
if (!$(e.target).closest('.pwd-btn').length) {
|
|
$('.pwd-btn').removeClass('active');
|
|
$('.pwd-btn').siblings('.speech-tooltip').removeClass('active');
|
|
}
|
|
});
|
|
|
|
console.log('Event handlers attached. Found', $('.unblock-btn').length, 'unblock buttons');
|
|
console.log('Event handlers attached. Found', $('.pwd-btn').length, 'password buttons');
|
|
console.log('Event handlers attached. Found', $('.ua-btn').length, 'user agent buttons');
|
|
});
|
|
</script>
|
|
{{end}}
|