Add generic webhook notification channel
Introduces a new 'Webhook (Custom)' notification channel allowing users to send JSON payloads to any HTTP endpoint (e.g., n8n, Zapier, custom APIs). Updates the UI to support webhook configuration, adds backend validation, and implements the WebhookChannel for sending notifications. Documentation is updated with usage instructions and payload examples.
This commit is contained in:
28
README.md
28
README.md
@@ -217,6 +217,34 @@ All application and email settings are now managed through the **Settings** page
|
|||||||
4. Copy the webhook URL
|
4. Copy the webhook URL
|
||||||
5. Add it in the notification group settings
|
5. Add it in the notification group settings
|
||||||
|
|
||||||
|
#### 🌐 Webhook (Custom)
|
||||||
|
|
||||||
|
Send JSON payloads to any HTTP endpoint (e.g., n8n, Zapier, Make, your own API):
|
||||||
|
|
||||||
|
1. Go to Notification Groups → Edit → Add Channel
|
||||||
|
2. Choose "Webhook (Custom)"
|
||||||
|
3. Paste your endpoint URL (HTTPS recommended)
|
||||||
|
4. Click "Test Channel" to verify
|
||||||
|
|
||||||
|
Payload example sent on domain alerts:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"event": "domain_expiration_alert",
|
||||||
|
"message": "⚠️ WARNING: Domain 'example.com' expires in 7 days (January 30, 2026)!\n\nRegistrar: Example Registrar\nPlease renew soon.",
|
||||||
|
"data": {
|
||||||
|
"domain": "example.com",
|
||||||
|
"domain_id": 123,
|
||||||
|
"days_left": 7,
|
||||||
|
"expiration_date": "2026-01-30",
|
||||||
|
"registrar": "Example Registrar"
|
||||||
|
},
|
||||||
|
"sent_at": "2025-10-17T12:34:56Z"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Use this with n8n's "Webhook" trigger to start flows.
|
||||||
|
|
||||||
## 📅 Setting Up Cron Jobs
|
## 📅 Setting Up Cron Jobs
|
||||||
|
|
||||||
The application requires a cron job to check domains periodically.
|
The application requires a cron job to check domains periodically.
|
||||||
|
|||||||
@@ -214,6 +214,7 @@ class NotificationGroupController extends Controller
|
|||||||
break;
|
break;
|
||||||
case 'discord':
|
case 'discord':
|
||||||
case 'slack':
|
case 'slack':
|
||||||
|
case 'webhook':
|
||||||
$missingField = 'webhook URL';
|
$missingField = 'webhook URL';
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -427,6 +428,17 @@ class NotificationGroupController extends Controller
|
|||||||
}
|
}
|
||||||
return ['webhook_url' => $webhookUrl];
|
return ['webhook_url' => $webhookUrl];
|
||||||
|
|
||||||
|
case 'webhook':
|
||||||
|
$webhookUrl = trim($data['webhook_url'] ?? '');
|
||||||
|
if (empty($webhookUrl) || !filter_var($webhookUrl, FILTER_VALIDATE_URL)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
// Optional: Allow any HTTPS URL; prefer HTTPS for security
|
||||||
|
if (!str_starts_with($webhookUrl, 'https://') && !str_starts_with($webhookUrl, 'http://')) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return ['webhook_url' => $webhookUrl];
|
||||||
|
|
||||||
default:
|
default:
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
48
app/Services/Channels/WebhookChannel.php
Normal file
48
app/Services/Channels/WebhookChannel.php
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Channels;
|
||||||
|
|
||||||
|
use GuzzleHttp\Client;
|
||||||
|
|
||||||
|
class WebhookChannel implements NotificationChannelInterface
|
||||||
|
{
|
||||||
|
private Client $httpClient;
|
||||||
|
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
$this->httpClient = new Client(['timeout' => 10]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function send(array $config, string $message, array $data = []): bool
|
||||||
|
{
|
||||||
|
$url = trim($config['webhook_url'] ?? '');
|
||||||
|
if (empty($url)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build a sane, generic JSON payload for automation tools (n8n, Zapier, etc.)
|
||||||
|
$payload = [
|
||||||
|
'event' => 'domain_expiration_alert',
|
||||||
|
'message' => $message,
|
||||||
|
'data' => $data,
|
||||||
|
'sent_at' => date('c')
|
||||||
|
];
|
||||||
|
|
||||||
|
try {
|
||||||
|
$response = $this->httpClient->post($url, [
|
||||||
|
'headers' => [
|
||||||
|
'Content-Type' => 'application/json'
|
||||||
|
],
|
||||||
|
'json' => $payload
|
||||||
|
]);
|
||||||
|
|
||||||
|
$status = $response->getStatusCode();
|
||||||
|
return $status >= 200 && $status < 300;
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
error_log('Webhook send failed: ' . $e->getMessage());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -6,6 +6,7 @@ use App\Services\Channels\EmailChannel;
|
|||||||
use App\Services\Channels\TelegramChannel;
|
use App\Services\Channels\TelegramChannel;
|
||||||
use App\Services\Channels\DiscordChannel;
|
use App\Services\Channels\DiscordChannel;
|
||||||
use App\Services\Channels\SlackChannel;
|
use App\Services\Channels\SlackChannel;
|
||||||
|
use App\Services\Channels\WebhookChannel;
|
||||||
|
|
||||||
class NotificationService
|
class NotificationService
|
||||||
{
|
{
|
||||||
@@ -18,6 +19,7 @@ class NotificationService
|
|||||||
'telegram' => new TelegramChannel(),
|
'telegram' => new TelegramChannel(),
|
||||||
'discord' => new DiscordChannel(),
|
'discord' => new DiscordChannel(),
|
||||||
'slack' => new SlackChannel(),
|
'slack' => new SlackChannel(),
|
||||||
|
'webhook' => new WebhookChannel(),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -78,9 +78,9 @@ ob_start();
|
|||||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 mb-6">
|
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 mb-6">
|
||||||
<?php foreach ($group['channels'] as $channel):
|
<?php foreach ($group['channels'] as $channel):
|
||||||
$config = json_decode($channel['channel_config'], true);
|
$config = json_decode($channel['channel_config'], true);
|
||||||
$icons = ['email' => 'fa-envelope', 'telegram' => 'fa-telegram', 'discord' => 'fa-discord', 'slack' => 'fa-slack'];
|
$icons = ['email' => 'fa-envelope', 'telegram' => 'fa-telegram', 'discord' => 'fa-discord', 'slack' => 'fa-slack', 'webhook' => 'fa-link'];
|
||||||
$iconClasses = ['email' => 'fas', 'telegram' => 'fab', 'discord' => 'fab', 'slack' => 'fab'];
|
$iconClasses = ['email' => 'fas', 'telegram' => 'fab', 'discord' => 'fab', 'slack' => 'fab', 'webhook' => 'fas'];
|
||||||
$colors = ['email' => 'blue', 'telegram' => 'blue', 'discord' => 'indigo', 'slack' => 'teal'];
|
$colors = ['email' => 'blue', 'telegram' => 'blue', 'discord' => 'indigo', 'slack' => 'teal', 'webhook' => 'purple'];
|
||||||
$icon = $icons[$channel['channel_type']] ?? 'fa-bell';
|
$icon = $icons[$channel['channel_type']] ?? 'fa-bell';
|
||||||
$iconClass = $iconClasses[$channel['channel_type']] ?? 'fas';
|
$iconClass = $iconClasses[$channel['channel_type']] ?? 'fas';
|
||||||
$color = $colors[$channel['channel_type']] ?? 'gray';
|
$color = $colors[$channel['channel_type']] ?? 'gray';
|
||||||
@@ -152,6 +152,7 @@ ob_start();
|
|||||||
<option value="telegram">Telegram</option>
|
<option value="telegram">Telegram</option>
|
||||||
<option value="discord">Discord</option>
|
<option value="discord">Discord</option>
|
||||||
<option value="slack">Slack</option>
|
<option value="slack">Slack</option>
|
||||||
|
<option value="webhook">Webhook (Custom)</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -236,6 +237,24 @@ ob_start();
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Generic Webhook Fields -->
|
||||||
|
<div id="webhook_fields" class="hidden space-y-4">
|
||||||
|
<div>
|
||||||
|
<label for="generic_webhook_url" class="block text-sm font-medium text-gray-700 mb-1.5">
|
||||||
|
Webhook URL
|
||||||
|
</label>
|
||||||
|
<input type="text"
|
||||||
|
id="generic_webhook_url"
|
||||||
|
name="webhook_url"
|
||||||
|
class="w-full px-3 py-2.5 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary focus:border-primary transition-colors text-sm font-mono"
|
||||||
|
placeholder="https://example.com/webhook-endpoint"
|
||||||
|
autocomplete="off">
|
||||||
|
<p class="mt-1.5 text-xs text-gray-500">
|
||||||
|
Will receive JSON payload compatible with n8n/Zapier/Make.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="flex gap-3">
|
<div class="flex gap-3">
|
||||||
<button type="submit"
|
<button type="submit"
|
||||||
class="inline-flex items-center px-5 py-2.5 bg-primary hover:bg-primary-dark text-white rounded-lg font-medium transition-colors text-sm">
|
class="inline-flex items-center px-5 py-2.5 bg-primary hover:bg-primary-dark text-white rounded-lg font-medium transition-colors text-sm">
|
||||||
@@ -314,6 +333,7 @@ function toggleChannelFields() {
|
|||||||
const chatIdField = document.getElementById('chat_id');
|
const chatIdField = document.getElementById('chat_id');
|
||||||
const discordWebhook = document.getElementById('discord_webhook');
|
const discordWebhook = document.getElementById('discord_webhook');
|
||||||
const slackWebhook = document.getElementById('slack_webhook');
|
const slackWebhook = document.getElementById('slack_webhook');
|
||||||
|
const genericWebhook = document.getElementById('generic_webhook_url');
|
||||||
|
|
||||||
// Remove required from all
|
// Remove required from all
|
||||||
emailField.removeAttribute('required');
|
emailField.removeAttribute('required');
|
||||||
@@ -321,12 +341,14 @@ function toggleChannelFields() {
|
|||||||
chatIdField.removeAttribute('required');
|
chatIdField.removeAttribute('required');
|
||||||
discordWebhook.removeAttribute('required');
|
discordWebhook.removeAttribute('required');
|
||||||
slackWebhook.removeAttribute('required');
|
slackWebhook.removeAttribute('required');
|
||||||
|
if (genericWebhook) genericWebhook.removeAttribute('required');
|
||||||
|
|
||||||
// Hide all fields
|
// Hide all fields
|
||||||
document.getElementById('email_fields').classList.add('hidden');
|
document.getElementById('email_fields').classList.add('hidden');
|
||||||
document.getElementById('telegram_fields').classList.add('hidden');
|
document.getElementById('telegram_fields').classList.add('hidden');
|
||||||
document.getElementById('discord_fields').classList.add('hidden');
|
document.getElementById('discord_fields').classList.add('hidden');
|
||||||
document.getElementById('slack_fields').classList.add('hidden');
|
document.getElementById('slack_fields').classList.add('hidden');
|
||||||
|
document.getElementById('webhook_fields').classList.add('hidden');
|
||||||
|
|
||||||
// Hide test button by default
|
// Hide test button by default
|
||||||
testBtn.classList.add('hidden');
|
testBtn.classList.add('hidden');
|
||||||
@@ -352,6 +374,12 @@ function toggleChannelFields() {
|
|||||||
slackWebhook.setAttribute('required', 'required');
|
slackWebhook.setAttribute('required', 'required');
|
||||||
slackWebhook.focus();
|
slackWebhook.focus();
|
||||||
break;
|
break;
|
||||||
|
case 'webhook':
|
||||||
|
if (genericWebhook) {
|
||||||
|
genericWebhook.setAttribute('required', 'required');
|
||||||
|
genericWebhook.focus();
|
||||||
|
}
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Show test button when channel type is selected
|
// Show test button when channel type is selected
|
||||||
@@ -400,6 +428,17 @@ if (addChannelForm) {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Validate Generic webhook
|
||||||
|
if (channelType === 'webhook') {
|
||||||
|
const webhookUrl = document.getElementById('generic_webhook_url').value.trim();
|
||||||
|
if (!webhookUrl) {
|
||||||
|
e.preventDefault();
|
||||||
|
alert('Please enter the Webhook URL');
|
||||||
|
document.getElementById('generic_webhook_url').focus();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
@@ -473,6 +512,13 @@ function testChannel(channelType, existingConfig = null) {
|
|||||||
errorMessage = 'Please enter a Slack webhook URL';
|
errorMessage = 'Please enter a Slack webhook URL';
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
case 'webhook':
|
||||||
|
const genericWebhook = document.getElementById('generic_webhook_url').value.trim();
|
||||||
|
if (!genericWebhook) {
|
||||||
|
isValid = false;
|
||||||
|
errorMessage = 'Please enter a Webhook URL';
|
||||||
|
}
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isValid) {
|
if (!isValid) {
|
||||||
@@ -531,6 +577,9 @@ function testChannel(channelType, existingConfig = null) {
|
|||||||
case 'slack':
|
case 'slack':
|
||||||
formData.append('slack_webhook_url', document.getElementById('slack_webhook').value);
|
formData.append('slack_webhook_url', document.getElementById('slack_webhook').value);
|
||||||
break;
|
break;
|
||||||
|
case 'webhook':
|
||||||
|
formData.append('webhook_url', document.getElementById('generic_webhook_url').value);
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -536,88 +536,86 @@ foreach ($notificationPresets as $key => $preset) {
|
|||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<!-- Two-Factor Authentication Settings -->
|
||||||
|
<div class="bg-white rounded-lg border border-gray-200 overflow-hidden mt-6">
|
||||||
|
<div class="px-6 py-4 border-b border-gray-200 bg-gray-50">
|
||||||
|
<h3 class="text-lg font-semibold text-gray-900">Two-Factor Authentication</h3>
|
||||||
|
<p class="text-sm text-gray-600 mt-1">Configure 2FA policy and security settings</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Two-Factor Authentication Settings -->
|
<form method="POST" action="/settings/update-two-factor" class="p-6">
|
||||||
<div class="bg-white rounded-lg border border-gray-200 overflow-hidden mt-6">
|
<?= csrf_field() ?>
|
||||||
<div class="px-6 py-4 border-b border-gray-200 bg-gray-50">
|
<div class="space-y-4">
|
||||||
<h3 class="text-lg font-semibold text-gray-900">Two-Factor Authentication</h3>
|
<div>
|
||||||
<p class="text-sm text-gray-600 mt-1">Configure 2FA policy and security settings</p>
|
<label for="two_factor_policy" class="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
2FA Policy
|
||||||
|
</label>
|
||||||
|
<select id="two_factor_policy" name="two_factor_policy"
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary focus:border-primary">
|
||||||
|
<option value="disabled" <?= ($twoFactorSettings['policy'] ?? 'optional') === 'disabled' ? 'selected' : '' ?>>
|
||||||
|
Disabled - No 2FA features available
|
||||||
|
</option>
|
||||||
|
<option value="optional" <?= ($twoFactorSettings['policy'] ?? 'optional') === 'optional' ? 'selected' : '' ?>>
|
||||||
|
Optional - Users can choose to enable 2FA
|
||||||
|
</option>
|
||||||
|
<option value="forced" <?= ($twoFactorSettings['policy'] ?? 'optional') === 'forced' ? 'selected' : '' ?>>
|
||||||
|
Forced - All users must enable 2FA (email verification required)
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
<p class="text-xs text-gray-500 mt-1">
|
||||||
|
<i class="fas fa-info-circle text-blue-500 mr-1"></i>
|
||||||
|
Users must have verified email addresses to enable 2FA
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label for="two_factor_rate_limit_minutes" class="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
Rate Limit (minutes)
|
||||||
|
</label>
|
||||||
|
<input type="number" id="two_factor_rate_limit_minutes" name="two_factor_rate_limit_minutes"
|
||||||
|
value="<?= htmlspecialchars($twoFactorSettings['rate_limit_minutes'] ?? 15) ?>"
|
||||||
|
min="1" max="60"
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary focus:border-primary">
|
||||||
|
<p class="text-xs text-gray-500 mt-1">Maximum failed attempts per IP address</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="two_factor_email_code_expiry_minutes" class="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
Email Code Expiry (minutes)
|
||||||
|
</label>
|
||||||
|
<input type="number" id="two_factor_email_code_expiry_minutes" name="two_factor_email_code_expiry_minutes"
|
||||||
|
value="<?= htmlspecialchars($twoFactorSettings['email_code_expiry_minutes'] ?? 10) ?>"
|
||||||
|
min="1" max="30"
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary focus:border-primary">
|
||||||
|
<p class="text-xs text-gray-500 mt-1">How long email backup codes remain valid</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 2FA Info Box -->
|
||||||
|
<div class="bg-blue-50 border border-blue-200 rounded-lg p-4">
|
||||||
|
<p class="text-sm font-medium text-gray-900 mb-2">
|
||||||
|
<i class="fas fa-info-circle text-blue-500 mr-1"></i>
|
||||||
|
Two-Factor Authentication Features
|
||||||
|
</p>
|
||||||
|
<ul class="text-sm text-gray-700 space-y-1">
|
||||||
|
<li>• <strong>TOTP Authenticator Apps:</strong> Google Authenticator, Authy, Microsoft Authenticator</li>
|
||||||
|
<li>• <strong>Email Backup Codes:</strong> One-time codes sent to verified email addresses</li>
|
||||||
|
<li>• <strong>Backup Recovery Codes:</strong> 8 single-use codes generated during setup</li>
|
||||||
|
<li>• <strong>Rate Limiting:</strong> Prevents brute force attacks on verification codes</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center justify-between pt-6 mt-6 border-t border-gray-200">
|
||||||
|
<button type="submit" class="inline-flex items-center px-4 py-2.5 bg-green-600 text-white text-sm rounded-lg hover:bg-green-700 transition-colors font-medium">
|
||||||
|
<i class="fas fa-shield-alt mr-2"></i>
|
||||||
|
Save 2FA Settings
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form method="POST" action="/settings/update-two-factor" class="p-6">
|
|
||||||
<?= csrf_field() ?>
|
|
||||||
<div class="space-y-4">
|
|
||||||
<div>
|
|
||||||
<label for="two_factor_policy" class="block text-sm font-medium text-gray-700 mb-2">
|
|
||||||
2FA Policy
|
|
||||||
</label>
|
|
||||||
<select id="two_factor_policy" name="two_factor_policy"
|
|
||||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary focus:border-primary">
|
|
||||||
<option value="disabled" <?= ($twoFactorSettings['policy'] ?? 'optional') === 'disabled' ? 'selected' : '' ?>>
|
|
||||||
Disabled - No 2FA features available
|
|
||||||
</option>
|
|
||||||
<option value="optional" <?= ($twoFactorSettings['policy'] ?? 'optional') === 'optional' ? 'selected' : '' ?>>
|
|
||||||
Optional - Users can choose to enable 2FA
|
|
||||||
</option>
|
|
||||||
<option value="forced" <?= ($twoFactorSettings['policy'] ?? 'optional') === 'forced' ? 'selected' : '' ?>>
|
|
||||||
Forced - All users must enable 2FA (email verification required)
|
|
||||||
</option>
|
|
||||||
</select>
|
|
||||||
<p class="text-xs text-gray-500 mt-1">
|
|
||||||
<i class="fas fa-info-circle text-blue-500 mr-1"></i>
|
|
||||||
Users must have verified email addresses to enable 2FA
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
||||||
<div>
|
|
||||||
<label for="two_factor_rate_limit_minutes" class="block text-sm font-medium text-gray-700 mb-2">
|
|
||||||
Rate Limit (minutes)
|
|
||||||
</label>
|
|
||||||
<input type="number" id="two_factor_rate_limit_minutes" name="two_factor_rate_limit_minutes"
|
|
||||||
value="<?= htmlspecialchars($twoFactorSettings['rate_limit_minutes'] ?? 15) ?>"
|
|
||||||
min="1" max="60"
|
|
||||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary focus:border-primary">
|
|
||||||
<p class="text-xs text-gray-500 mt-1">Maximum failed attempts per IP address</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label for="two_factor_email_code_expiry_minutes" class="block text-sm font-medium text-gray-700 mb-2">
|
|
||||||
Email Code Expiry (minutes)
|
|
||||||
</label>
|
|
||||||
<input type="number" id="two_factor_email_code_expiry_minutes" name="two_factor_email_code_expiry_minutes"
|
|
||||||
value="<?= htmlspecialchars($twoFactorSettings['email_code_expiry_minutes'] ?? 10) ?>"
|
|
||||||
min="1" max="30"
|
|
||||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary focus:border-primary">
|
|
||||||
<p class="text-xs text-gray-500 mt-1">How long email backup codes remain valid</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 2FA Info Box -->
|
|
||||||
<div class="bg-blue-50 border border-blue-200 rounded-lg p-4">
|
|
||||||
<p class="text-sm font-medium text-gray-900 mb-2">
|
|
||||||
<i class="fas fa-info-circle text-blue-500 mr-1"></i>
|
|
||||||
Two-Factor Authentication Features
|
|
||||||
</p>
|
|
||||||
<ul class="text-sm text-gray-700 space-y-1">
|
|
||||||
<li>• <strong>TOTP Authenticator Apps:</strong> Google Authenticator, Authy, Microsoft Authenticator</li>
|
|
||||||
<li>• <strong>Email Backup Codes:</strong> One-time codes sent to verified email addresses</li>
|
|
||||||
<li>• <strong>Backup Recovery Codes:</strong> 8 single-use codes generated during setup</li>
|
|
||||||
<li>• <strong>Rate Limiting:</strong> Prevents brute force attacks on verification codes</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex items-center justify-between pt-6 mt-6 border-t border-gray-200">
|
|
||||||
<button type="submit" class="inline-flex items-center px-4 py-2.5 bg-green-600 text-white text-sm rounded-lg hover:bg-green-700 transition-colors font-medium">
|
|
||||||
<i class="fas fa-shield-alt mr-2"></i>
|
|
||||||
Save 2FA Settings
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Tab Content: System Information -->
|
<!-- Tab Content: System Information -->
|
||||||
<div id="content-system" class="tab-content hidden">
|
<div id="content-system" class="tab-content hidden">
|
||||||
<div class="bg-white rounded-lg border border-gray-200 overflow-hidden">
|
<div class="bg-white rounded-lg border border-gray-200 overflow-hidden">
|
||||||
|
|||||||
Reference in New Issue
Block a user