Add two-factor authentication (2FA) support
Introduces two-factor authentication (2FA) with TOTP, backup codes, and email codes. Adds controllers, services, views, and migration for 2FA setup, verification, and management. Updates user and settings models, email helper, and relevant controllers to support 2FA policy enforcement, configuration, and user flows. Enhances security by allowing admins to require or disable 2FA, and provides backup code generation and management for account recovery.
This commit is contained in:
209
app/Views/2fa/backup-codes.php
Normal file
209
app/Views/2fa/backup-codes.php
Normal file
@@ -0,0 +1,209 @@
|
||||
<?php
|
||||
$title = '2FA Backup Codes';
|
||||
$pageTitle = '2FA Backup Codes';
|
||||
$pageDescription = 'Save these backup codes in a safe place';
|
||||
$pageIcon = 'fas fa-key';
|
||||
ob_start();
|
||||
?>
|
||||
|
||||
<div class="max-w-2xl mx-auto">
|
||||
<div class="bg-white shadow rounded-lg">
|
||||
<div class="px-6 py-4 border-b border-gray-200">
|
||||
<h3 class="text-lg font-semibold text-gray-900">2FA Backup Codes</h3>
|
||||
<p class="text-sm text-gray-600 mt-1">Save these codes in a safe place - they can be used to access your account if you lose your authenticator device</p>
|
||||
</div>
|
||||
|
||||
<div class="p-6">
|
||||
<!-- Warning -->
|
||||
<div class="bg-red-50 border border-red-200 rounded-lg p-4 mb-6">
|
||||
<div class="flex items-start">
|
||||
<div class="flex-shrink-0">
|
||||
<i class="fas fa-exclamation-triangle text-red-400"></i>
|
||||
</div>
|
||||
<div class="ml-3">
|
||||
<h4 class="text-sm font-medium text-red-800">Important Security Notice</h4>
|
||||
<p class="text-sm text-red-700 mt-1">
|
||||
These backup codes are shown only once. Each code can only be used once. Store them securely and never share them with anyone.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Backup Codes -->
|
||||
<div class="mb-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h4 class="text-md font-semibold text-gray-900">Your Backup Codes</h4>
|
||||
<button onclick="printCodes()" class="text-sm text-primary hover:text-primary-dark">
|
||||
<i class="fas fa-print mr-1"></i>
|
||||
Print Codes
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-3" id="backup-codes">
|
||||
<?php foreach ($backupCodes as $index => $code): ?>
|
||||
<div class="flex items-center justify-between bg-gray-50 p-3 rounded-lg border">
|
||||
<code class="font-mono text-sm text-gray-900"><?= htmlspecialchars($code) ?></code>
|
||||
<button onclick="copyCode('<?= htmlspecialchars($code) ?>', this)"
|
||||
class="ml-2 px-2 py-1 text-xs bg-gray-200 text-gray-700 rounded hover:bg-gray-300">
|
||||
<i class="fas fa-copy"></i>
|
||||
</button>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Instructions -->
|
||||
<div class="bg-blue-50 border border-blue-200 rounded-lg p-4 mb-6">
|
||||
<h4 class="text-sm font-medium text-blue-800 mb-2">How to use backup codes:</h4>
|
||||
<ul class="text-sm text-blue-700 space-y-1">
|
||||
<li>• When logging in, enter a backup code instead of your 2FA code</li>
|
||||
<li>• Each backup code can only be used once</li>
|
||||
<li>• After using a code, it will be automatically removed from your account</li>
|
||||
<li>• If you run out of backup codes, you'll need to disable and re-enable 2FA</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex items-center justify-between pt-4 border-t border-gray-200">
|
||||
<a href="/profile" class="text-sm text-gray-600 hover:text-gray-500">
|
||||
<i class="fas fa-arrow-left mr-1"></i>
|
||||
Back to Profile
|
||||
</a>
|
||||
<div class="flex space-x-3">
|
||||
<button onclick="downloadCodes()"
|
||||
class="inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary">
|
||||
<i class="fas fa-download mr-2"></i>
|
||||
Download
|
||||
</button>
|
||||
<a href="/"
|
||||
class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-primary hover:bg-primary-dark focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary">
|
||||
<i class="fas fa-check mr-2"></i>
|
||||
I've Saved These Codes
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function copyCode(code, button) {
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
navigator.clipboard.writeText(code).then(() => {
|
||||
showCopySuccess(button);
|
||||
}).catch(() => {
|
||||
fallbackCopyTextToClipboard(code);
|
||||
});
|
||||
} else {
|
||||
fallbackCopyTextToClipboard(code);
|
||||
}
|
||||
}
|
||||
|
||||
function showCopySuccess(button) {
|
||||
const originalHTML = button.innerHTML;
|
||||
button.innerHTML = '<i class="fas fa-check"></i>';
|
||||
button.classList.remove('bg-gray-200', 'hover:bg-gray-300', 'text-gray-700');
|
||||
button.classList.add('bg-green-500', 'text-white');
|
||||
|
||||
setTimeout(() => {
|
||||
button.innerHTML = originalHTML;
|
||||
button.classList.remove('bg-green-500', 'text-white');
|
||||
button.classList.add('bg-gray-200', 'hover:bg-gray-300', 'text-gray-700');
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
function fallbackCopyTextToClipboard(text) {
|
||||
const textArea = document.createElement('textarea');
|
||||
textArea.value = text;
|
||||
textArea.style.position = 'fixed';
|
||||
textArea.style.top = '0';
|
||||
textArea.style.left = '0';
|
||||
textArea.style.width = '2em';
|
||||
textArea.style.height = '2em';
|
||||
textArea.style.padding = '0';
|
||||
textArea.style.border = 'none';
|
||||
textArea.style.outline = 'none';
|
||||
textArea.style.boxShadow = 'none';
|
||||
textArea.style.background = 'transparent';
|
||||
|
||||
document.body.appendChild(textArea);
|
||||
textArea.focus();
|
||||
textArea.select();
|
||||
|
||||
try {
|
||||
document.execCommand('copy');
|
||||
alert('Code copied to clipboard!');
|
||||
} catch (err) {
|
||||
console.error('Copy failed:', err);
|
||||
alert('Failed to copy code');
|
||||
}
|
||||
|
||||
document.body.removeChild(textArea);
|
||||
}
|
||||
|
||||
function printCodes() {
|
||||
const printWindow = window.open('', '_blank');
|
||||
const codes = <?= json_encode($backupCodes) ?>;
|
||||
|
||||
printWindow.document.write(`
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>2FA Backup Codes - <?= htmlspecialchars($user['full_name'] ?? $user['username']) ?></title>
|
||||
<style>
|
||||
body { font-family: Arial, sans-serif; padding: 20px; line-height: 1.6; }
|
||||
h1 { color: #333; margin-bottom: 20px; }
|
||||
.codes { background: #f5f5f5; padding: 15px; border: 1px solid #ddd; margin: 20px 0; }
|
||||
.code { margin: 5px 0; font-family: monospace; }
|
||||
.warning { background: #fee; border: 1px solid #fcc; padding: 10px; margin: 10px 0; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>2FA Backup Codes</h1>
|
||||
<p><strong>Account:</strong> <?= htmlspecialchars($user['full_name'] ?? $user['username']) ?> (<?= htmlspecialchars($user['email']) ?>)</p>
|
||||
<p><strong>Generated:</strong> ${new Date().toLocaleString()}</p>
|
||||
|
||||
<div class="warning">
|
||||
<strong>Important:</strong> Store these codes in a safe place. Each code can only be used once.
|
||||
</div>
|
||||
|
||||
<div class="codes">
|
||||
${codes.map((code, index) => `<div class="code">${index + 1}. ${code}</div>`).join('')}
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`);
|
||||
|
||||
printWindow.document.close();
|
||||
printWindow.print();
|
||||
}
|
||||
|
||||
function downloadCodes() {
|
||||
const codes = <?= json_encode($backupCodes) ?>;
|
||||
const content = `2FA Backup Codes
|
||||
Account: <?= htmlspecialchars($user['full_name'] ?? $user['username']) ?> (<?= htmlspecialchars($user['email']) ?>)
|
||||
Generated: ${new Date().toLocaleString()}
|
||||
|
||||
IMPORTANT: Store these codes in a safe place. Each code can only be used once.
|
||||
|
||||
${codes.map((code, index) => `${index + 1}. ${code}`).join('\n')}
|
||||
|
||||
If you lose access to your authenticator app, you can use these codes to log in.
|
||||
Generate new codes if you run out or if you suspect they've been compromised.`;
|
||||
|
||||
const blob = new Blob([content], { type: 'text/plain' });
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = '2fa-backup-codes.txt';
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
window.URL.revokeObjectURL(url);
|
||||
}
|
||||
</script>
|
||||
|
||||
<?php
|
||||
$content = ob_get_clean();
|
||||
require __DIR__ . '/../layout/base.php';
|
||||
?>
|
||||
162
app/Views/2fa/setup.php
Normal file
162
app/Views/2fa/setup.php
Normal file
@@ -0,0 +1,162 @@
|
||||
<?php
|
||||
$title = 'Setup Two-Factor Authentication';
|
||||
$pageTitle = 'Setup 2FA';
|
||||
$pageDescription = 'Configure two-factor authentication for your account';
|
||||
$pageIcon = 'fas fa-shield-alt';
|
||||
ob_start();
|
||||
?>
|
||||
|
||||
<div class="max-w-3xl mx-auto">
|
||||
<div class="bg-white rounded-lg border border-gray-200 overflow-hidden">
|
||||
<div class="px-6 py-4 border-b border-gray-200">
|
||||
<h2 class="text-lg font-semibold text-gray-900 flex items-center">
|
||||
<i class="fas fa-shield-alt text-gray-400 mr-2 text-sm"></i>
|
||||
Setup Two-Factor Authentication
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div class="p-6 space-y-5">
|
||||
<!-- Step 1: Download Authenticator App -->
|
||||
<div class="border-l-4 border-blue-500 pl-4">
|
||||
<h4 class="text-base font-semibold text-gray-900 mb-2">Step 1: Install an Authenticator App</h4>
|
||||
<p class="text-sm text-gray-600 mb-3">Download one of these apps on your mobile device:</p>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-3">
|
||||
<div class="bg-gray-50 rounded-lg p-3 text-center">
|
||||
<i class="fab fa-google text-2xl text-blue-600 mb-2"></i>
|
||||
<p class="text-sm font-medium text-gray-900">Google Authenticator</p>
|
||||
<p class="text-xs text-gray-500">iOS & Android</p>
|
||||
</div>
|
||||
<div class="bg-gray-50 rounded-lg p-3 text-center">
|
||||
<i class="fas fa-mobile-alt text-2xl text-blue-600 mb-2"></i>
|
||||
<p class="text-sm font-medium text-gray-900">Authy</p>
|
||||
<p class="text-xs text-gray-500">iOS & Android</p>
|
||||
</div>
|
||||
<div class="bg-gray-50 rounded-lg p-3 text-center">
|
||||
<i class="fab fa-microsoft text-2xl text-blue-600 mb-2"></i>
|
||||
<p class="text-sm font-medium text-gray-900">Microsoft Authenticator</p>
|
||||
<p class="text-xs text-gray-500">iOS & Android</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 2: Scan QR Code -->
|
||||
<div class="border-l-4 border-green-500 pl-4">
|
||||
<h4 class="text-base font-semibold text-gray-900 mb-2">Step 2: Scan QR Code</h4>
|
||||
<p class="text-sm text-gray-600 mb-4">Open your authenticator app and scan this QR code:</p>
|
||||
|
||||
<div class="bg-blue-50 border border-blue-200 rounded-lg p-3 mb-4">
|
||||
<div class="flex items-center">
|
||||
<i class="fas fa-info-circle text-blue-600 mr-2"></i>
|
||||
<p class="text-sm text-blue-800">
|
||||
<strong>Note:</strong> This QR code will remain the same even if you refresh the page.
|
||||
Once you scan it, you can enter the verification code below.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col items-center space-y-4">
|
||||
<div class="bg-white border-2 border-gray-200 rounded-lg p-4">
|
||||
<img src="<?= htmlspecialchars($qrCodeUrl) ?>" alt="QR Code for 2FA setup" class="w-48 h-48">
|
||||
</div>
|
||||
|
||||
<div class="text-center">
|
||||
<p class="text-xs text-gray-500 mb-2">Can't scan? Enter this code manually:</p>
|
||||
<div class="bg-gray-100 rounded-lg p-3 font-mono text-sm">
|
||||
<code class="text-gray-800"><?= htmlspecialchars($secret) ?></code>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 3: Verify Code -->
|
||||
<div class="border-l-4 border-yellow-500 pl-4">
|
||||
<h4 class="text-base font-semibold text-gray-900 mb-2">Step 3: Verify Setup</h4>
|
||||
<p class="text-sm text-gray-600 mb-4">Enter the 6-digit code from your authenticator app:</p>
|
||||
|
||||
<form method="POST" action="/2fa/verify-setup" id="verifyForm">
|
||||
<?= csrf_field() ?>
|
||||
|
||||
<div class="max-w-xs mx-auto">
|
||||
<input type="text"
|
||||
name="verification_code"
|
||||
id="verification_code"
|
||||
maxlength="6"
|
||||
pattern="[0-9]{6}"
|
||||
placeholder="123456"
|
||||
class="w-full px-4 py-3 text-center text-2xl font-mono border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary focus:border-primary transition-colors text-sm"
|
||||
required>
|
||||
<p class="text-xs text-gray-500 mt-2 text-center">Enter 6-digit code</p>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col sm:flex-row gap-3 pt-3">
|
||||
<button type="submit"
|
||||
class="inline-flex items-center justify-center px-5 py-2.5 bg-primary hover:bg-primary-dark text-white rounded-lg font-medium transition-colors text-sm">
|
||||
<i class="fas fa-check mr-2"></i>
|
||||
Verify & Enable 2FA
|
||||
</button>
|
||||
<a href="/2fa/cancel-setup"
|
||||
class="inline-flex items-center justify-center px-5 py-2.5 border border-gray-300 text-gray-700 rounded-lg font-medium hover:bg-gray-50 transition-colors text-sm">
|
||||
<i class="fas fa-times mr-2"></i>
|
||||
Cancel
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Security Notice -->
|
||||
<div class="bg-yellow-50 border border-yellow-200 rounded-lg p-4">
|
||||
<div class="flex">
|
||||
<i class="fas fa-exclamation-triangle text-yellow-600 mt-0.5 mr-3"></i>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-yellow-900">Important Security Notice</p>
|
||||
<p class="text-sm text-yellow-700 mt-1">
|
||||
Once 2FA is enabled, you'll need your authenticator app to log in.
|
||||
Make sure to save your backup codes in a secure location.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const codeInput = document.getElementById('verification_code');
|
||||
|
||||
// Auto-focus on code input
|
||||
codeInput.focus();
|
||||
|
||||
// Only allow digits
|
||||
codeInput.addEventListener('input', function(e) {
|
||||
this.value = this.value.replace(/[^0-9]/g, '');
|
||||
});
|
||||
|
||||
// Auto-submit when 6 digits are entered
|
||||
codeInput.addEventListener('input', function(e) {
|
||||
if (this.value.length === 6) {
|
||||
// Small delay to let user see the complete code
|
||||
setTimeout(() => {
|
||||
document.getElementById('verifyForm').submit();
|
||||
}, 500);
|
||||
}
|
||||
});
|
||||
|
||||
// Handle form submission
|
||||
document.getElementById('verifyForm').addEventListener('submit', function(e) {
|
||||
const code = codeInput.value.trim();
|
||||
if (code.length !== 6 || !/^\d{6}$/.test(code)) {
|
||||
e.preventDefault();
|
||||
alert('Please enter a valid 6-digit code');
|
||||
codeInput.focus();
|
||||
return false;
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<?php
|
||||
$content = ob_get_clean();
|
||||
require __DIR__ . '/../layout/base.php';
|
||||
?>
|
||||
206
app/Views/2fa/verify.php
Normal file
206
app/Views/2fa/verify.php
Normal file
@@ -0,0 +1,206 @@
|
||||
<?php
|
||||
$title = '2FA Verification';
|
||||
ob_start();
|
||||
|
||||
$twoFactorService = new \App\Services\TwoFactorService();
|
||||
$canSendEmailCode = $user['email_verified'] && $twoFactorService->checkRateLimit($_SERVER['REMOTE_ADDR'] ?? '', $user['id']);
|
||||
?>
|
||||
|
||||
<div class="text-center mb-6">
|
||||
<div class="mx-auto h-12 w-12 flex items-center justify-center rounded-full bg-primary bg-opacity-10 mb-4">
|
||||
<i class="fas fa-shield-alt text-primary text-xl"></i>
|
||||
</div>
|
||||
<h2 class="text-2xl font-bold text-gray-900 mb-2">
|
||||
2FA Verification
|
||||
</h2>
|
||||
<p class="text-sm text-gray-600">
|
||||
Hello, <strong><?= htmlspecialchars($user['full_name'] ?? $user['username']) ?></strong>!<br>
|
||||
Please enter your 2FA code to complete login.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<?php if (isset($_SESSION['error'])): ?>
|
||||
<div class="bg-red-50 border border-red-200 rounded-lg p-3 mb-4">
|
||||
<div class="flex items-center">
|
||||
<i class="fas fa-exclamation-circle text-red-500 mr-2"></i>
|
||||
<span class="text-sm text-red-700"><?= htmlspecialchars($_SESSION['error']) ?></span>
|
||||
</div>
|
||||
</div>
|
||||
<?php unset($_SESSION['error']); ?>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if (isset($_SESSION['success'])): ?>
|
||||
<div class="bg-green-50 border border-green-200 rounded-lg p-3 mb-4">
|
||||
<div class="flex items-center">
|
||||
<i class="fas fa-check-circle text-green-500 mr-2"></i>
|
||||
<span class="text-sm text-green-700"><?= htmlspecialchars($_SESSION['success']) ?></span>
|
||||
</div>
|
||||
</div>
|
||||
<?php unset($_SESSION['success']); ?>
|
||||
<?php endif; ?>
|
||||
|
||||
<form class="space-y-4" method="POST" action="/2fa/verify" id="verifyForm">
|
||||
<?= csrf_field() ?>
|
||||
|
||||
<!-- Security verification completed during login -->
|
||||
<div class="bg-green-50 border border-green-200 rounded-lg p-3 mb-4">
|
||||
<div class="flex items-center">
|
||||
<i class="fas fa-check-circle text-green-500 mr-2"></i>
|
||||
<span class="text-sm text-green-700">Security verification completed during login</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="code" class="block text-sm font-medium text-gray-700 mb-2">
|
||||
2FA Code
|
||||
</label>
|
||||
<input id="code" name="verification_code" type="text" required
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-center text-lg font-mono tracking-widest focus:ring-2 focus:ring-primary focus:border-primary transition-colors"
|
||||
placeholder="000000" maxlength="8" autocomplete="one-time-code" autofocus>
|
||||
<p class="text-xs text-gray-500 mt-1 text-center">Enter 6-digit code from your authenticator app, email code, or 8-character backup code</p>
|
||||
</div>
|
||||
|
||||
<button type="submit"
|
||||
class="w-full bg-primary text-white py-2 px-4 rounded-lg hover:bg-primary-dark focus:ring-2 focus:ring-primary focus:ring-offset-2 transition-colors">
|
||||
<i class="fas fa-check mr-2"></i>
|
||||
Verify Code
|
||||
</button>
|
||||
|
||||
<div class="flex items-center justify-between text-sm">
|
||||
<?php if ($canSendEmailCode): ?>
|
||||
<button type="button" onclick="sendEmailCode()"
|
||||
class="text-primary hover:text-primary-dark transition-colors">
|
||||
<i class="fas fa-envelope mr-1"></i>
|
||||
Send Email Code
|
||||
</button>
|
||||
<?php else: ?>
|
||||
<span class="text-gray-400">
|
||||
<i class="fas fa-envelope mr-1"></i>
|
||||
Email code unavailable
|
||||
</span>
|
||||
<?php endif; ?>
|
||||
<a href="/logout" class="text-gray-600 hover:text-gray-500 transition-colors">
|
||||
<i class="fas fa-sign-out-alt mr-1"></i>
|
||||
Sign out instead
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 pt-4 border-t border-gray-200">
|
||||
<div class="text-center">
|
||||
<p class="text-sm text-gray-600">
|
||||
Having trouble? You can also use a backup code or contact your administrator for help.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<script>
|
||||
function sendEmailCode() {
|
||||
const btn = event.target;
|
||||
const originalText = btn.innerHTML;
|
||||
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '<i class="fas fa-spinner fa-spin mr-1"></i>Sending...';
|
||||
|
||||
fetch('/2fa/send-email-code', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
'X-CSRF-Token': document.querySelector('input[name="csrf_token"]').value
|
||||
}
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
btn.innerHTML = '<i class="fas fa-check mr-1"></i>Code Sent';
|
||||
btn.classList.remove('text-primary', 'hover:text-primary-dark');
|
||||
btn.classList.add('text-green-600');
|
||||
|
||||
// Reset button after 30 seconds
|
||||
setTimeout(() => {
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = originalText;
|
||||
btn.classList.remove('text-green-600');
|
||||
btn.classList.add('text-primary', 'hover:text-primary-dark');
|
||||
}, 30000);
|
||||
} else {
|
||||
alert('Failed to send email code: ' + (data.error || 'Unknown error'));
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = originalText;
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
alert('Failed to send email code');
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = originalText;
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const codeInput = document.getElementById('code');
|
||||
const form = document.getElementById('verifyForm');
|
||||
|
||||
// Auto-focus on code input
|
||||
codeInput.focus();
|
||||
|
||||
// Handle input validation and auto-submit
|
||||
codeInput.addEventListener('input', function(e) {
|
||||
// Allow digits, letters for backup codes
|
||||
this.value = this.value.replace(/[^A-Za-z0-9]/g, '');
|
||||
|
||||
// Auto-submit when 6 digits are entered (TOTP/email codes)
|
||||
if (this.value.length === 6 && /^\d{6}$/.test(this.value)) {
|
||||
setTimeout(() => {
|
||||
form.submit();
|
||||
}, 500);
|
||||
}
|
||||
|
||||
// Auto-submit when 8 characters are entered (backup codes)
|
||||
if (this.value.length === 8 && /^[A-Z0-9]{8}$/i.test(this.value)) {
|
||||
setTimeout(() => {
|
||||
form.submit();
|
||||
}, 500);
|
||||
}
|
||||
});
|
||||
|
||||
// Form validation
|
||||
form.addEventListener('submit', function(e) {
|
||||
const code = codeInput.value.trim();
|
||||
|
||||
// Check if code is entered
|
||||
if (!code) {
|
||||
e.preventDefault();
|
||||
alert('Please enter a verification code');
|
||||
codeInput.focus();
|
||||
return false;
|
||||
}
|
||||
|
||||
// Validate code format
|
||||
if (code.length === 6 && !/^\d{6}$/.test(code)) {
|
||||
e.preventDefault();
|
||||
alert('Please enter a valid 6-digit code');
|
||||
codeInput.focus();
|
||||
return false;
|
||||
}
|
||||
|
||||
if (code.length === 8 && !/^[A-Z0-9]{8}$/i.test(code)) {
|
||||
e.preventDefault();
|
||||
alert('Please enter a valid 8-character backup code');
|
||||
codeInput.focus();
|
||||
return false;
|
||||
}
|
||||
|
||||
if (code.length < 6 || code.length > 8) {
|
||||
e.preventDefault();
|
||||
alert('Please enter a valid verification code (6 digits or 8 characters)');
|
||||
codeInput.focus();
|
||||
return false;
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<?php
|
||||
$content = ob_get_clean();
|
||||
require __DIR__ . '/../auth/base-auth.php';
|
||||
?>
|
||||
Reference in New Issue
Block a user