Switch PHP views to Twig and add 2FA/UI enhancements
Migrate many view templates from raw PHP to Twig and modernize UI/UX for 2FA and settings. Controllers updated to provide avatar data and two-factor info (ProfileController, UserController) and SettingsController now includes timezone lists, notification preset selection, cron path, cached update state and rollback availability. ErrorHandler now attempts to render error pages via a new Core\TwigService with a safe fallback to raw PHP views. TwoFactorService generation silences deprecated warnings during QR code creation. Numerous .php view files were removed and replaced with .twig equivalents (2fa setup/verify/backup-codes and many auth, dashboard, domains, errors, layout, users, tags, tld-registry, etc.), and core/TwigService was added. These changes move the app toward a Twig-based templating system, improve 2FA flows, surface avatar images in lists/profiles, and make error rendering more robust.
This commit is contained in:
@@ -1,209 +0,0 @@
|
||||
<?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';
|
||||
?>
|
||||
124
app/Views/2fa/backup-codes.twig
Normal file
124
app/Views/2fa/backup-codes.twig
Normal file
@@ -0,0 +1,124 @@
|
||||
{% extends 'layout/base.twig' %}
|
||||
|
||||
{% set title = '2FA Backup Codes' %}
|
||||
{% set pageTitle = '2FA Backup Codes' %}
|
||||
{% set pageDescription = 'Save these backup codes in a safe place' %}
|
||||
{% set pageIcon = 'fas fa-key' %}
|
||||
|
||||
{% block content %}
|
||||
<div class="max-w-2xl mx-auto">
|
||||
<div class="bg-white dark:bg-slate-800 shadow rounded-lg border border-gray-200 dark:border-slate-700">
|
||||
<div class="px-6 py-4 border-b border-gray-200 dark:border-slate-700">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">2FA Backup Codes</h3>
|
||||
<p class="text-sm text-gray-600 dark:text-slate-400 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 dark:bg-red-500/10 border border-red-200 dark:border-red-500/20 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 dark:text-red-400"></i>
|
||||
</div>
|
||||
<div class="ml-3">
|
||||
<h4 class="text-sm font-medium text-red-800 dark:text-red-300">Important Security Notice</h4>
|
||||
<p class="text-sm text-red-700 dark:text-red-400/80 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 dark:text-white">Your Backup Codes</h4>
|
||||
<button onclick="printCodes()" class="text-sm text-primary hover:text-primary-dark dark:text-blue-400 dark:hover:text-blue-300">
|
||||
<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">
|
||||
{% for code in backupCodes %}
|
||||
<div class="flex items-center justify-between bg-gray-50 dark:bg-slate-700/50 p-3 rounded-lg border border-gray-200 dark:border-slate-600">
|
||||
<code class="font-mono text-sm text-gray-900 dark:text-slate-200">{{ code }}</code>
|
||||
<button onclick="copyCode('{{ code }}', this)" class="ml-2 px-2 py-1 text-xs bg-gray-200 dark:bg-slate-600 text-gray-700 dark:text-slate-300 rounded hover:bg-gray-300 dark:hover:bg-slate-500 transition-colors">
|
||||
<i class="fas fa-copy"></i>
|
||||
</button>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Instructions #}
|
||||
<div class="bg-blue-50 dark:bg-blue-500/10 border border-blue-200 dark:border-blue-500/20 rounded-lg p-4 mb-6">
|
||||
<h4 class="text-sm font-medium text-blue-800 dark:text-blue-300 mb-2">How to use backup codes:</h4>
|
||||
<ul class="text-sm text-blue-700 dark:text-blue-400/80 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 dark:border-slate-700">
|
||||
<a href="/profile" class="text-sm text-gray-600 dark:text-slate-400 hover:text-gray-500 dark:hover:text-slate-300">
|
||||
<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 dark:border-slate-600 text-sm font-medium rounded-md text-gray-700 dark:text-slate-300 bg-white dark:bg-slate-700 hover:bg-gray-50 dark:hover:bg-slate-600 transition-colors">
|
||||
<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 transition-colors">
|
||||
<i class="fas fa-check mr-2"></i>I've Saved These Codes
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const backupCodesData = {{ backupCodes|json_encode|raw }};
|
||||
const userName = '{{ user.full_name|default(user.username) }}';
|
||||
const userEmail = '{{ user.email }}';
|
||||
|
||||
function copyCode(code, button) {
|
||||
navigator.clipboard.writeText(code).then(() => {
|
||||
const originalHTML = button.innerHTML;
|
||||
button.innerHTML = '<i class="fas fa-check"></i>';
|
||||
button.classList.replace('bg-gray-200', 'bg-green-500');
|
||||
button.classList.replace('text-gray-700', 'text-white');
|
||||
button.classList.replace('dark:bg-slate-600', 'dark:bg-green-600');
|
||||
button.classList.replace('dark:text-slate-300', 'dark:text-white');
|
||||
setTimeout(() => {
|
||||
button.innerHTML = originalHTML;
|
||||
button.classList.replace('bg-green-500', 'bg-gray-200');
|
||||
button.classList.replace('text-white', 'text-gray-700');
|
||||
button.classList.replace('dark:bg-green-600', 'dark:bg-slate-600');
|
||||
button.classList.replace('dark:text-white', 'dark:text-slate-300');
|
||||
}, 2000);
|
||||
});
|
||||
}
|
||||
|
||||
function printCodes() {
|
||||
const printWindow = window.open('', '_blank');
|
||||
printWindow.document.write(`<!DOCTYPE html><html><head><title>2FA Backup Codes</title>
|
||||
<style>body{font-family:Arial,sans-serif;padding:20px}.codes{background:#f5f5f5;padding:15px;border:1px solid #ddd;margin:20px 0}.code{margin:5px 0;font-family:monospace}</style>
|
||||
</head><body><h1>2FA Backup Codes</h1><p><strong>Account:</strong> ${userName} (${userEmail})</p><p><strong>Generated:</strong> ${new Date().toLocaleString()}</p>
|
||||
<div class="codes">${backupCodesData.map((c,i) => '<div class="code">'+(i+1)+'. '+c+'</div>').join('')}</div></body></html>`);
|
||||
printWindow.document.close();
|
||||
printWindow.print();
|
||||
}
|
||||
|
||||
function downloadCodes() {
|
||||
const content = `2FA Backup Codes\nAccount: ${userName} (${userEmail})\nGenerated: ${new Date().toLocaleString()}\n\n${backupCodesData.map((c,i) => (i+1)+'. '+c).join('\n')}`;
|
||||
const blob = new Blob([content], {type: 'text/plain'});
|
||||
const a = document.createElement('a');
|
||||
a.href = URL.createObjectURL(blob);
|
||||
a.download = '2fa-backup-codes.txt';
|
||||
a.click();
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -1,162 +0,0 @@
|
||||
<?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';
|
||||
?>
|
||||
116
app/Views/2fa/setup.twig
Normal file
116
app/Views/2fa/setup.twig
Normal file
@@ -0,0 +1,116 @@
|
||||
{% extends 'layout/base.twig' %}
|
||||
|
||||
{% set title = 'Setup Two-Factor Authentication' %}
|
||||
{% set pageTitle = 'Setup 2FA' %}
|
||||
{% set pageDescription = 'Configure two-factor authentication for your account' %}
|
||||
{% set pageIcon = 'fas fa-shield-alt' %}
|
||||
|
||||
{% block content %}
|
||||
<div class="max-w-3xl mx-auto">
|
||||
<div class="bg-white dark:bg-slate-800 rounded-lg border border-gray-200 dark:border-slate-700 overflow-hidden">
|
||||
<div class="px-6 py-4 border-b border-gray-200 dark:border-slate-700">
|
||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-white flex items-center">
|
||||
<i class="fas fa-shield-alt text-gray-400 dark:text-slate-500 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 dark:text-white mb-2">Step 1: Install an Authenticator App</h4>
|
||||
<p class="text-sm text-gray-600 dark:text-slate-400 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 dark:bg-slate-700/50 rounded-lg p-3 text-center">
|
||||
<i class="fab fa-google text-2xl text-blue-600 dark:text-blue-400 mb-2"></i>
|
||||
<p class="text-sm font-medium text-gray-900 dark:text-white">Google Authenticator</p>
|
||||
<p class="text-xs text-gray-500 dark:text-slate-400">iOS & Android</p>
|
||||
</div>
|
||||
<div class="bg-gray-50 dark:bg-slate-700/50 rounded-lg p-3 text-center">
|
||||
<i class="fas fa-mobile-alt text-2xl text-blue-600 dark:text-blue-400 mb-2"></i>
|
||||
<p class="text-sm font-medium text-gray-900 dark:text-white">Authy</p>
|
||||
<p class="text-xs text-gray-500 dark:text-slate-400">iOS & Android</p>
|
||||
</div>
|
||||
<div class="bg-gray-50 dark:bg-slate-700/50 rounded-lg p-3 text-center">
|
||||
<i class="fab fa-microsoft text-2xl text-blue-600 dark:text-blue-400 mb-2"></i>
|
||||
<p class="text-sm font-medium text-gray-900 dark:text-white">Microsoft Authenticator</p>
|
||||
<p class="text-xs text-gray-500 dark:text-slate-400">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 dark:text-white mb-2">Step 2: Scan QR Code</h4>
|
||||
<p class="text-sm text-gray-600 dark:text-slate-400 mb-4">Open your authenticator app and scan this QR code:</p>
|
||||
|
||||
<div class="flex flex-col items-center space-y-4">
|
||||
<div class="bg-white border-2 border-gray-200 dark:border-slate-600 rounded-lg p-4">
|
||||
<img src="{{ qrCodeUrl }}" alt="QR Code for 2FA setup" class="w-48 h-48">
|
||||
</div>
|
||||
|
||||
<div class="text-center">
|
||||
<p class="text-xs text-gray-500 dark:text-slate-400 mb-2">Can't scan? Enter this code manually:</p>
|
||||
<div class="bg-gray-100 dark:bg-slate-700 rounded-lg p-3 font-mono text-sm">
|
||||
<code class="text-gray-800 dark:text-slate-200">{{ 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 dark:text-white mb-2">Step 3: Verify Setup</h4>
|
||||
<p class="text-sm text-gray-600 dark:text-slate-400 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 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-700 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-slate-500 focus:ring-2 focus:ring-primary focus:border-primary" required>
|
||||
<p class="text-xs text-gray-500 dark:text-slate-400 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 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 dark:border-slate-600 text-gray-700 dark:text-slate-300 rounded-lg font-medium hover:bg-gray-50 dark:hover:bg-slate-700 text-sm">
|
||||
<i class="fas fa-times mr-2"></i>Cancel
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{# Security Notice #}
|
||||
<div class="bg-yellow-50 dark:bg-yellow-500/10 border border-yellow-200 dark:border-yellow-500/20 rounded-lg p-4">
|
||||
<div class="flex">
|
||||
<i class="fas fa-exclamation-triangle text-yellow-600 dark:text-yellow-400 mt-0.5 mr-3"></i>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-yellow-900 dark:text-yellow-300">Important Security Notice</p>
|
||||
<p class="text-sm text-yellow-700 dark:text-yellow-400/80 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');
|
||||
codeInput.focus();
|
||||
codeInput.addEventListener('input', function() {
|
||||
this.value = this.value.replace(/[^0-9]/g, '');
|
||||
if (this.value.length === 6) {
|
||||
setTimeout(() => document.getElementById('verifyForm').submit(), 500);
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -1,206 +0,0 @@
|
||||
<?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';
|
||||
?>
|
||||
124
app/Views/2fa/verify.twig
Normal file
124
app/Views/2fa/verify.twig
Normal file
@@ -0,0 +1,124 @@
|
||||
{% extends 'auth/base-auth.twig' %}
|
||||
|
||||
{% set title = '2FA Verification' %}
|
||||
|
||||
{% block content %}
|
||||
<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 dark:bg-primary/20 mb-4">
|
||||
<i class="fas fa-shield-alt text-primary text-xl"></i>
|
||||
</div>
|
||||
<h2 class="text-2xl font-bold text-gray-900 dark:text-white mb-2">2FA Verification</h2>
|
||||
<p class="text-sm text-gray-600 dark:text-slate-400">
|
||||
Hello, <strong class="text-gray-900 dark:text-white">{{ user.full_name|default(user.username) }}</strong>!<br>
|
||||
Please enter your 2FA code to complete login.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{% if flash.success is defined %}
|
||||
<div class="mb-4 bg-green-50 dark:bg-green-900/30 border border-green-200 dark:border-green-800 p-3 rounded-lg">
|
||||
<div class="flex items-center">
|
||||
<i class="fas fa-check-circle text-green-500 dark:text-green-400 mr-2"></i>
|
||||
<span class="text-sm text-green-700 dark:text-green-300">{{ flash.success }}</span>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if flash.error is defined %}
|
||||
<div class="mb-4 bg-red-50 dark:bg-red-900/30 border border-red-200 dark:border-red-800 p-3 rounded-lg">
|
||||
<div class="flex items-center">
|
||||
<i class="fas fa-exclamation-circle text-red-500 dark:text-red-400 mr-2"></i>
|
||||
<span class="text-sm text-red-700 dark:text-red-300">{{ flash.error }}</span>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<form class="space-y-4" method="POST" action="/2fa/verify" id="verifyForm">
|
||||
{{ csrf_field() }}
|
||||
|
||||
<div class="bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-lg p-3 mb-4">
|
||||
<div class="flex items-center">
|
||||
<i class="fas fa-check-circle text-green-500 dark:text-green-400 mr-2"></i>
|
||||
<span class="text-sm text-green-700 dark:text-green-300">Security verification completed during login</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="code" class="block text-sm font-medium text-gray-700 dark:text-slate-300 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 dark:border-slate-600 rounded-lg text-center text-lg font-mono tracking-widest bg-white dark:bg-slate-700 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-slate-500 focus:ring-2 focus:ring-primary focus:border-primary"
|
||||
placeholder="000000" maxlength="8" autocomplete="one-time-code" autofocus>
|
||||
<p class="text-xs text-gray-500 dark:text-slate-400 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 transition-colors">
|
||||
<i class="fas fa-check mr-2"></i>Verify Code
|
||||
</button>
|
||||
|
||||
<div class="flex items-center justify-between text-sm">
|
||||
{% if canSendEmailCode %}
|
||||
<button type="button" onclick="sendEmailCode()" class="text-primary hover:text-primary-dark dark:text-blue-400 dark:hover:text-blue-300">
|
||||
<i class="fas fa-envelope mr-1"></i>Send Email Code
|
||||
</button>
|
||||
{% else %}
|
||||
<span class="text-gray-400 dark:text-slate-500"><i class="fas fa-envelope mr-1"></i>Email code unavailable</span>
|
||||
{% endif %}
|
||||
<a href="/logout" class="text-gray-600 dark:text-slate-400 hover:text-gray-500 dark:hover:text-slate-300">
|
||||
<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 dark:border-slate-700 text-center">
|
||||
<p class="text-sm text-gray-600 dark:text-slate-400">Having trouble? You can also use a backup code or contact your administrator.</p>
|
||||
</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.add('text-green-600');
|
||||
setTimeout(() => {
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = originalText;
|
||||
btn.classList.remove('text-green-600');
|
||||
}, 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');
|
||||
codeInput.focus();
|
||||
codeInput.addEventListener('input', function() {
|
||||
this.value = this.value.replace(/[^A-Za-z0-9]/g, '');
|
||||
if ((this.value.length === 6 && /^\d{6}$/.test(this.value)) ||
|
||||
(this.value.length === 8 && /^[A-Z0-9]{8}$/i.test(this.value))) {
|
||||
setTimeout(() => document.getElementById('verifyForm').submit(), 500);
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user