Enhance DNS discovery, validation & transfers
Add comprehensive DNS management and input validation, plus safer transfer and logging behavior. - Add CronHelper utilities for cron scripts and unify logging/formatting. - Improve InputValidator: sanitizeDomainInput and validateRootDomain (handles multi-level TLDs) and use throughout domain import/create flows to reject subdomains. - DomainController: refactor DNS refresh to support quick/deep discovery (background deep scans), add endpoints to discover, add/delete/bulk-delete DNS records, import BIND zone files, enrich IP metadata via enrichIpDetails, and strengthen bulk import/reporting messages. - DnsRecord model: add source column handling (discovered/manual/imported), avoid auto-deleting manual/imported records, and add helpers for deleting, bulk deleting, manual adding and importing zone records. - Tag, NotificationGroup and Domain transfer logic: unlink groups when ownership changes, remove tags that belong to other users, add audit logging via Logger and improved bulk transfer reporting. TagController/View: show transferable users for admins and skip global tags on transfer. - Notification channels (Discord, Mattermost, etc.) and EmailHelper: allow explicit subjects and improve payload fields based on notification type. - Add new migration 029_add_dns_record_source.sql and wire it into the installer; update migrations detection. - Add new views/partials for confirm/import/transfer modals, update various domain/group/tag templates, and update cron scripts and routes for discovery. These changes preserve manual/imported DNS records, improve root-domain validation, enable background deep discovery, and add better logging/audit trails for transfers and imports.
This commit is contained in:
@@ -205,7 +205,7 @@
|
||||
<span class="text-sm font-medium text-gray-700 dark:text-slate-300">Refresh WHOIS</span>
|
||||
</button>
|
||||
</form>
|
||||
<form method="POST" action="/domains/{{ domain.id }}/delete" onsubmit="return confirm('Delete this domain permanently?')" class="m-0">
|
||||
<form method="POST" action="/domains/{{ domain.id }}/delete" onsubmit="return confirmSubmit(event, 'Delete this domain permanently?')" class="m-0">
|
||||
{{ csrf_field() }}
|
||||
<button type="submit"
|
||||
class="w-full flex items-center justify-center p-3 bg-white dark:bg-slate-800 border border-gray-200 dark:border-slate-700 rounded-lg hover:border-red-300 dark:hover:border-red-700 hover:bg-red-50 dark:hover:bg-red-500/10 transition-colors group">
|
||||
|
||||
@@ -375,10 +375,16 @@
|
||||
<i class="fas fa-sync-alt"></i>
|
||||
</button>
|
||||
</form>
|
||||
{% if auth.isAdmin %}
|
||||
<button type="button" class="domain-transfer-btn text-indigo-600 hover:text-indigo-800 dark:text-indigo-400 dark:hover:text-indigo-300" title="Transfer Domain"
|
||||
data-domain-id="{{ domain.id }}" data-domain-name="{{ domain.domain_name }}">
|
||||
<i class="fas fa-exchange-alt"></i>
|
||||
</button>
|
||||
{% endif %}
|
||||
<a href="/domains/{{ domain.id }}/edit?from=/domains" class="text-yellow-600 hover:text-yellow-800 dark:text-yellow-400 dark:hover:text-yellow-300" title="Edit">
|
||||
<i class="fas fa-edit"></i>
|
||||
</a>
|
||||
<form method="POST" action="/domains/{{ domain.id }}/delete" class="inline" onsubmit="return confirm('Delete this domain?')">
|
||||
<form method="POST" action="/domains/{{ domain.id }}/delete" class="inline" onsubmit="return confirmSubmit(event, 'Delete this domain?')">
|
||||
{{ csrf_field() }}
|
||||
<button type="submit" class="text-red-600 hover:text-red-800 dark:text-red-400 dark:hover:text-red-300" title="Delete">
|
||||
<i class="fas fa-trash"></i>
|
||||
@@ -398,7 +404,18 @@
|
||||
<div class="p-6 hover:bg-gray-50 dark:hover:bg-slate-700 transition-colors duration-150">
|
||||
<div class="flex items-center mb-3">
|
||||
<input type="checkbox" class="domain-checkbox-mobile rounded border-gray-300 dark:border-slate-600 text-primary focus:ring-primary mr-3" value="{{ domain.id }}" onchange="updateBulkActions()">
|
||||
<a href="/domains/{{ domain.id }}" class="text-lg font-semibold text-gray-900 dark:text-white hover:text-primary">{{ domain.domain_name }}</a>
|
||||
<a href="/domains/{{ domain.id }}" class="flex-1 text-lg font-semibold text-gray-900 dark:text-white hover:text-primary">{{ domain.domain_name }}</a>
|
||||
<div class="flex items-center space-x-2">
|
||||
<a href="/domains/{{ domain.id }}" class="text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300" title="View">
|
||||
<i class="fas fa-eye"></i>
|
||||
</a>
|
||||
{% if auth.isAdmin %}
|
||||
<button type="button" class="domain-transfer-btn text-indigo-600 hover:text-indigo-800 dark:text-indigo-400 dark:hover:text-indigo-300" title="Transfer Domain"
|
||||
data-domain-id="{{ domain.id }}" data-domain-name="{{ domain.domain_name }}">
|
||||
<i class="fas fa-exchange-alt"></i>
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
@@ -566,13 +583,12 @@ function bulkRefresh() {
|
||||
form.submit();
|
||||
}
|
||||
|
||||
function bulkDelete() {
|
||||
async function bulkDelete() {
|
||||
const ids = getSelectedIds();
|
||||
if (ids.length === 0) return;
|
||||
|
||||
if (!confirm(`Delete ${ids.length} domain(s)? This action cannot be undone.`)) {
|
||||
return;
|
||||
}
|
||||
var ok = await confirmAction({ message: 'Delete ' + ids.length + ' domain(s)? This action cannot be undone.' });
|
||||
if (!ok) return;
|
||||
|
||||
const form = document.createElement('form');
|
||||
form.method = 'POST';
|
||||
@@ -641,16 +657,15 @@ function bulkAddTag(tagName) {
|
||||
form.submit();
|
||||
}
|
||||
|
||||
function bulkRemoveAllTags() {
|
||||
async function bulkRemoveAllTags() {
|
||||
const ids = getSelectedIds();
|
||||
if (ids.length === 0) {
|
||||
alert('Please select at least one domain');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!confirm(`Remove all tags from ${ids.length} domain(s)?`)) {
|
||||
return;
|
||||
}
|
||||
var ok = await confirmAction({ message: 'Remove all tags from ' + ids.length + ' domain(s)?', title: 'Remove Tags', icon: 'fa-tags text-orange-500' });
|
||||
if (!ok) return;
|
||||
|
||||
const form = document.createElement('form');
|
||||
form.method = 'POST';
|
||||
@@ -680,51 +695,15 @@ function bulkTransfer() {
|
||||
alert('Please select at least one domain');
|
||||
return;
|
||||
}
|
||||
|
||||
const users = {{ users|default([])|json_encode|raw }};
|
||||
if (users.length === 0) {
|
||||
alert('No users available for transfer');
|
||||
return;
|
||||
}
|
||||
|
||||
let userOptions = users.map(user =>
|
||||
`<option value="${user.id}">${user.username} (${user.full_name || user.email})</option>`
|
||||
).join('');
|
||||
|
||||
const modal = document.createElement('div');
|
||||
modal.className = 'fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full z-50';
|
||||
modal.innerHTML = `
|
||||
<div class="relative top-20 mx-auto p-5 border border-gray-200 dark:border-slate-700 w-96 shadow-lg rounded-md bg-white dark:bg-slate-800">
|
||||
<div class="mt-3">
|
||||
<h3 class="text-lg font-medium text-gray-900 dark:text-white mb-4">Transfer ${ids.length} Domain(s)</h3>
|
||||
<p class="text-sm text-gray-500 dark:text-slate-400 mb-4">Select the user to transfer the selected domains to:</p>
|
||||
|
||||
<form method="POST" action="/domains/bulk-transfer">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
${ids.map(id => `<input type="hidden" name="domain_ids[]" value="${id}">`).join('')}
|
||||
|
||||
<div class="mb-4">
|
||||
<label for="target_user_id" class="block text-sm font-medium text-gray-700 dark:text-slate-300 mb-2">Transfer to User:</label>
|
||||
<select name="target_user_id" id="target_user_id" required class="w-full px-3 py-2 border border-gray-300 dark:border-slate-600 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white dark:bg-slate-900 text-gray-900 dark:text-white">
|
||||
<option value="">Select a user...</option>
|
||||
${userOptions}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end space-x-3">
|
||||
<button type="button" onclick="this.closest('.fixed').remove()" class="px-4 py-2 bg-gray-300 dark:bg-slate-600 text-gray-700 dark:text-slate-300 rounded-md hover:bg-gray-400 dark:hover:bg-slate-500">
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit" class="px-4 py-2 bg-primary text-white rounded-md hover:bg-primary-dark">
|
||||
Transfer Domains
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
document.body.appendChild(modal);
|
||||
openTransferModal({
|
||||
title: 'Transfer ' + ids.length + ' Domain(s)',
|
||||
description: 'Select the user to transfer the selected domains to.',
|
||||
action: '/domains/bulk-transfer',
|
||||
fields: { 'domain_ids[]': ids },
|
||||
submitText: 'Transfer Domains',
|
||||
users: {{ users|default([])|json_encode|raw }},
|
||||
csrfToken: '{{ csrf_token() }}'
|
||||
});
|
||||
}
|
||||
|
||||
document.getElementById('bulk-assign-form')?.addEventListener('submit', function(e) {
|
||||
@@ -977,5 +956,26 @@ document.addEventListener('click', function(e) {
|
||||
document.getElementById('domainExportMenu').classList.add('hidden');
|
||||
}
|
||||
});
|
||||
|
||||
function transferDomain(domainId, domainName) {
|
||||
const esc = (s) => String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');
|
||||
openTransferModal({
|
||||
title: 'Transfer Domain',
|
||||
description: 'Transfer <strong>' + esc(domainName) + '</strong> to another user.',
|
||||
action: '/domains/transfer',
|
||||
fields: { domain_id: domainId },
|
||||
users: {{ users|default([])|json_encode|raw }},
|
||||
csrfToken: '{{ csrf_token() }}'
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener('click', function(e) {
|
||||
const btn = e.target.closest('.domain-transfer-btn');
|
||||
if (btn) {
|
||||
e.preventDefault();
|
||||
transferDomain(parseInt(btn.dataset.domainId, 10), btn.dataset.domainName || '');
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% include 'partials/transfer-modal.twig' %}
|
||||
{% endblock %}
|
||||
|
||||
@@ -23,22 +23,32 @@
|
||||
<div class="bg-white dark:bg-slate-800 rounded-lg border border-gray-200 dark:border-slate-700 p-8 text-center">
|
||||
<i class="fas fa-network-wired text-gray-300 dark:text-slate-600 mb-3" style="font-size: 36px;"></i>
|
||||
<h3 class="text-sm font-semibold text-gray-900 dark:text-white mb-1">No DNS Records Yet</h3>
|
||||
<p class="text-xs text-gray-500 dark:text-slate-400 mb-4">Click "Refresh DNS" to fetch the current DNS records for this domain.</p>
|
||||
<p class="text-xs text-gray-500 dark:text-slate-400 mb-4">Run a quick scan, import a zone file, or add records manually.</p>
|
||||
{% if domain %}
|
||||
<form method="POST" action="/domains/{{ domain.id }}/refresh-dns" class="inline" onsubmit="return handleDnsRefresh(this)">
|
||||
{{ csrf_field()|raw }}
|
||||
<button type="submit" class="dns-refresh-btn inline-flex items-center px-4 py-2 bg-green-600 text-white text-xs rounded-lg hover:bg-green-700 transition-colors font-medium">
|
||||
<i class="fas fa-sync-alt mr-1.5" style="font-size: 10px;"></i>
|
||||
<span class="btn-label">Refresh DNS</span>
|
||||
<div class="flex items-center justify-center gap-2 flex-wrap">
|
||||
<form method="POST" action="/domains/{{ domain.id }}/discover-dns" class="inline">
|
||||
{{ csrf_field()|raw }}
|
||||
<input type="hidden" name="mode" value="quick">
|
||||
<button type="submit" class="inline-flex items-center px-4 py-2 bg-blue-600 text-white text-xs rounded-lg hover:bg-blue-700 transition-colors font-medium">
|
||||
<i class="fas fa-bolt mr-1.5" style="font-size: 10px;"></i>Quick Scan
|
||||
</button>
|
||||
</form>
|
||||
<button type="button" onclick="document.getElementById('addDnsRecordModal').classList.remove('hidden')"
|
||||
class="inline-flex items-center px-4 py-2 bg-green-600 text-white text-xs rounded-lg hover:bg-green-700 transition-colors font-medium">
|
||||
<i class="fas fa-plus mr-1.5" style="font-size: 10px;"></i>Add Record
|
||||
</button>
|
||||
</form>
|
||||
<button type="button" onclick="document.getElementById('dnsZoneImportModal').classList.remove('hidden')"
|
||||
class="inline-flex items-center px-4 py-2 border border-gray-300 dark:border-slate-600 text-gray-700 dark:text-slate-300 text-xs rounded-lg hover:bg-gray-50 dark:hover:bg-slate-700 transition-colors font-medium">
|
||||
<i class="fas fa-file-import mr-1.5" style="font-size: 10px;"></i>Import Zone
|
||||
</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% else %}
|
||||
|
||||
<!-- Action Bar -->
|
||||
<div class="flex justify-between items-center mb-3">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-2 mb-3">
|
||||
<div class="flex items-center gap-3 flex-wrap">
|
||||
<p class="text-xs text-gray-600 dark:text-slate-400">
|
||||
<i class="far fa-clock mr-1"></i>
|
||||
Last checked: {{ domain.dns_last_checked ? domain.dns_last_checked|date('M d, Y H:i') : 'Never' }}
|
||||
@@ -51,13 +61,63 @@
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if domain and dnsMonitoringEnabled %}
|
||||
<form method="POST" action="/domains/{{ domain.id }}/refresh-dns" class="inline" onsubmit="return handleDnsRefresh(this)">
|
||||
{{ csrf_field()|raw }}
|
||||
<button type="submit" class="dns-refresh-btn inline-flex items-center px-3 py-2 bg-green-600 text-white text-xs rounded-lg hover:bg-green-700 transition-colors font-medium">
|
||||
<i class="fas fa-sync-alt mr-1.5" style="font-size: 10px;"></i>
|
||||
<span class="btn-label">Refresh DNS</span>
|
||||
<div class="flex items-center gap-2 flex-wrap">
|
||||
{# Refresh DNS (re-check existing only) #}
|
||||
<form method="POST" action="/domains/{{ domain.id }}/refresh-dns" class="inline" onsubmit="return handleDnsRefresh(this)">
|
||||
{{ csrf_field()|raw }}
|
||||
<button type="submit" class="dns-refresh-btn inline-flex items-center px-3 py-1.5 bg-green-600 text-white text-xs rounded-lg hover:bg-green-700 transition-colors font-medium">
|
||||
<i class="fas fa-sync-alt mr-1.5" style="font-size: 10px;"></i>
|
||||
<span class="btn-label">Refresh DNS</span>
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{# Discover DNS dropdown #}
|
||||
<div class="relative" id="discoverDropdown">
|
||||
<button type="button" onclick="document.getElementById('discoverMenu').classList.toggle('hidden')"
|
||||
class="inline-flex items-center px-3 py-1.5 bg-blue-600 text-white text-xs rounded-lg hover:bg-blue-700 transition-colors font-medium">
|
||||
<i class="fas fa-search mr-1.5" style="font-size: 10px;"></i>
|
||||
Discover DNS
|
||||
<i class="fas fa-caret-down ml-1.5" style="font-size: 10px;"></i>
|
||||
</button>
|
||||
<div id="discoverMenu" class="hidden absolute right-0 mt-1 w-56 bg-white dark:bg-slate-800 border border-gray-200 dark:border-slate-700 rounded-lg shadow-lg z-30">
|
||||
<form method="POST" action="/domains/{{ domain.id }}/discover-dns">
|
||||
{{ csrf_field()|raw }}
|
||||
<input type="hidden" name="mode" value="quick">
|
||||
<button type="submit" class="w-full text-left px-4 py-2.5 text-xs hover:bg-gray-50 dark:hover:bg-slate-700 rounded-t-lg transition-colors">
|
||||
<div class="font-semibold text-gray-900 dark:text-white"><i class="fas fa-bolt text-amber-500 mr-1.5"></i>Quick Scan</div>
|
||||
<p class="text-gray-500 dark:text-slate-400 mt-0.5">Root domain + NS/MX targets</p>
|
||||
</button>
|
||||
</form>
|
||||
<form method="POST" action="/domains/{{ domain.id }}/discover-dns">
|
||||
{{ csrf_field()|raw }}
|
||||
<input type="hidden" name="mode" value="deep">
|
||||
<button type="submit" class="w-full text-left px-4 py-2.5 text-xs hover:bg-gray-50 dark:hover:bg-slate-700 rounded-b-lg border-t border-gray-100 dark:border-slate-700 transition-colors">
|
||||
<div class="font-semibold text-gray-900 dark:text-white"><i class="fas fa-microscope text-purple-500 mr-1.5"></i>Deep Scan</div>
|
||||
<p class="text-gray-500 dark:text-slate-400 mt-0.5">Brute force + crt.sh (background)</p>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Add Record #}
|
||||
<button type="button" onclick="document.getElementById('addDnsRecordModal').classList.remove('hidden')"
|
||||
class="inline-flex items-center px-3 py-1.5 border border-gray-300 dark:border-slate-600 text-gray-700 dark:text-slate-300 text-xs rounded-lg hover:bg-gray-50 dark:hover:bg-slate-700 transition-colors font-medium">
|
||||
<i class="fas fa-plus mr-1.5" style="font-size: 10px;"></i>Add Record
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{# Import Zone #}
|
||||
<button type="button" onclick="document.getElementById('dnsZoneImportModal').classList.remove('hidden')"
|
||||
class="inline-flex items-center px-3 py-1.5 border border-gray-300 dark:border-slate-600 text-gray-700 dark:text-slate-300 text-xs rounded-lg hover:bg-gray-50 dark:hover:bg-slate-700 transition-colors font-medium">
|
||||
<i class="fas fa-file-import mr-1.5" style="font-size: 10px;"></i>Import Zone
|
||||
</button>
|
||||
|
||||
{# Bulk delete (shown when records are selected) #}
|
||||
<button type="button" id="dnsBulkDeleteBtn" class="hidden inline-flex items-center px-3 py-1.5 bg-red-600 text-white text-xs rounded-lg hover:bg-red-700 transition-colors font-medium"
|
||||
onclick="submitDnsBulkDelete()">
|
||||
<i class="fas fa-trash-alt mr-1.5" style="font-size: 10px;"></i>
|
||||
Delete Selected (<span id="dnsSelectedCount">0</span>)
|
||||
</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
@@ -131,23 +191,31 @@
|
||||
<table class="min-w-full">
|
||||
<thead class="bg-gray-50 dark:bg-slate-900">
|
||||
<tr>
|
||||
<th class="w-8 px-2 py-2"><input type="checkbox" class="dns-select-all rounded border-gray-300 dark:border-slate-600" data-type="A"></th>
|
||||
<th class="px-4 py-2 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase">Host</th>
|
||||
<th class="px-4 py-2 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase">IP Address</th>
|
||||
<th class="px-4 py-2 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase">PTR</th>
|
||||
<th class="px-4 py-2 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase">ASN</th>
|
||||
<th class="px-4 py-2 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase">TTL</th>
|
||||
<th class="w-8 px-2 py-2"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200 dark:divide-slate-700">
|
||||
{% for record in dnsRecords['A'] %}
|
||||
{% set ipInfo = dnsIpDetails[record.value]|default(null) %}
|
||||
<tr class="hover:bg-gray-50 dark:hover:bg-slate-700">
|
||||
<td class="w-8 px-2 py-2"><input type="checkbox" class="dns-record-cb rounded border-gray-300 dark:border-slate-600" value="{{ record.id }}"></td>
|
||||
<td class="px-4 py-2 text-xs font-medium text-gray-900 dark:text-white">
|
||||
{% if record.host == '@' %}
|
||||
<span class="text-blue-600 dark:text-blue-400">@ (root)</span>
|
||||
{% else %}
|
||||
{{ record.host }}
|
||||
{% endif %}
|
||||
{% if record.source|default('discovered') == 'manual' %}
|
||||
<span class="ml-1 px-1 py-0.5 bg-blue-100 dark:bg-blue-500/10 text-blue-700 dark:text-blue-400 text-xs rounded font-medium" style="font-size: 9px;">manual</span>
|
||||
{% elseif record.source|default('discovered') == 'imported' %}
|
||||
<span class="ml-1 px-1 py-0.5 bg-purple-100 dark:bg-purple-500/10 text-purple-700 dark:text-purple-400 text-xs rounded font-medium" style="font-size: 9px;">imported</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="px-4 py-2 text-xs">
|
||||
<span class="font-mono text-gray-900 dark:text-white">{{ record.value }}</span>
|
||||
@@ -177,6 +245,14 @@
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="px-4 py-2 text-xs text-gray-600 dark:text-slate-400">{{ record.ttl }}s</td>
|
||||
<td class="w-8 px-2 py-2">
|
||||
<form method="POST" action="/domains/{{ domain.id }}/dns-records/{{ record.id }}/delete" class="inline" onsubmit="return confirmSubmit(event, 'Delete this DNS record?')">
|
||||
{{ csrf_field()|raw }}
|
||||
<button type="submit" class="text-gray-400 hover:text-red-500 dark:text-slate-500 dark:hover:text-red-400 transition-colors" title="Delete">
|
||||
<i class="fas fa-trash-alt" style="font-size: 10px;"></i>
|
||||
</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
@@ -199,23 +275,31 @@
|
||||
<table class="min-w-full">
|
||||
<thead class="bg-gray-50 dark:bg-slate-900">
|
||||
<tr>
|
||||
<th class="w-8 px-2 py-2"><input type="checkbox" class="dns-select-all rounded border-gray-300 dark:border-slate-600" data-type="AAAA"></th>
|
||||
<th class="px-4 py-2 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase">Host</th>
|
||||
<th class="px-4 py-2 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase">IPv6 Address</th>
|
||||
<th class="px-4 py-2 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase">PTR</th>
|
||||
<th class="px-4 py-2 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase">ASN</th>
|
||||
<th class="px-4 py-2 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase">TTL</th>
|
||||
<th class="w-8 px-2 py-2"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200 dark:divide-slate-700">
|
||||
{% for record in dnsRecords['AAAA'] %}
|
||||
{% set ipInfo = dnsIpDetails[record.value]|default(null) %}
|
||||
<tr class="hover:bg-gray-50 dark:hover:bg-slate-700">
|
||||
<td class="w-8 px-2 py-2"><input type="checkbox" class="dns-record-cb rounded border-gray-300 dark:border-slate-600" value="{{ record.id }}"></td>
|
||||
<td class="px-4 py-2 text-xs font-medium text-gray-900 dark:text-white">
|
||||
{% if record.host == '@' %}
|
||||
<span class="text-indigo-600 dark:text-indigo-400">@ (root)</span>
|
||||
{% else %}
|
||||
{{ record.host }}
|
||||
{% endif %}
|
||||
{% if record.source|default('discovered') == 'manual' %}
|
||||
<span class="ml-1 px-1 py-0.5 bg-blue-100 dark:bg-blue-500/10 text-blue-700 dark:text-blue-400 text-xs rounded font-medium" style="font-size: 9px;">manual</span>
|
||||
{% elseif record.source|default('discovered') == 'imported' %}
|
||||
<span class="ml-1 px-1 py-0.5 bg-purple-100 dark:bg-purple-500/10 text-purple-700 dark:text-purple-400 text-xs rounded font-medium" style="font-size: 9px;">imported</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="px-4 py-2 text-xs">
|
||||
<span class="font-mono text-gray-900 dark:text-white">{{ record.value }}</span>
|
||||
@@ -245,6 +329,14 @@
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="px-4 py-2 text-xs text-gray-600 dark:text-slate-400">{{ record.ttl }}s</td>
|
||||
<td class="w-8 px-2 py-2">
|
||||
<form method="POST" action="/domains/{{ domain.id }}/dns-records/{{ record.id }}/delete" class="inline" onsubmit="return confirmSubmit(event, 'Delete this DNS record?')">
|
||||
{{ csrf_field()|raw }}
|
||||
<button type="submit" class="text-gray-400 hover:text-red-500 dark:text-slate-500 dark:hover:text-red-400 transition-colors" title="Delete">
|
||||
<i class="fas fa-trash-alt" style="font-size: 10px;"></i>
|
||||
</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
@@ -267,17 +359,32 @@
|
||||
<table class="min-w-full">
|
||||
<thead class="bg-gray-50 dark:bg-slate-900">
|
||||
<tr>
|
||||
<th class="w-8 px-2 py-2"><input type="checkbox" class="dns-select-all rounded border-gray-300 dark:border-slate-600" data-type="CNAME"></th>
|
||||
<th class="px-4 py-2 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase">Alias</th>
|
||||
<th class="px-4 py-2 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase">Target</th>
|
||||
<th class="px-4 py-2 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase">TTL</th>
|
||||
<th class="w-8 px-2 py-2"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200 dark:divide-slate-700">
|
||||
{% for record in dnsRecords['CNAME'] %}
|
||||
<tr class="hover:bg-gray-50 dark:hover:bg-slate-700">
|
||||
<td class="px-4 py-2 text-xs font-mono text-gray-900 dark:text-white">{{ record.host }}</td>
|
||||
<td class="w-8 px-2 py-2"><input type="checkbox" class="dns-record-cb rounded border-gray-300 dark:border-slate-600" value="{{ record.id }}"></td>
|
||||
<td class="px-4 py-2 text-xs font-mono text-gray-900 dark:text-white">{{ record.host }}{% if record.source|default('discovered') == 'manual' %}
|
||||
<span class="ml-1 px-1 py-0.5 bg-blue-100 dark:bg-blue-500/10 text-blue-700 dark:text-blue-400 text-xs rounded font-medium" style="font-size: 9px;">manual</span>
|
||||
{% elseif record.source|default('discovered') == 'imported' %}
|
||||
<span class="ml-1 px-1 py-0.5 bg-purple-100 dark:bg-purple-500/10 text-purple-700 dark:text-purple-400 text-xs rounded font-medium" style="font-size: 9px;">imported</span>
|
||||
{% endif %}</td>
|
||||
<td class="px-4 py-2 text-xs font-mono text-blue-600 dark:text-blue-400">{{ record.value }}</td>
|
||||
<td class="px-4 py-2 text-xs text-gray-600 dark:text-slate-400">{{ record.ttl }}s</td>
|
||||
<td class="w-8 px-2 py-2">
|
||||
<form method="POST" action="/domains/{{ domain.id }}/dns-records/{{ record.id }}/delete" class="inline" onsubmit="return confirmSubmit(event, 'Delete this DNS record?')">
|
||||
{{ csrf_field()|raw }}
|
||||
<button type="submit" class="text-gray-400 hover:text-red-500 dark:text-slate-500 dark:hover:text-red-400 transition-colors" title="Delete">
|
||||
<i class="fas fa-trash-alt" style="font-size: 10px;"></i>
|
||||
</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
@@ -300,19 +407,34 @@
|
||||
<table class="min-w-full">
|
||||
<thead class="bg-gray-50 dark:bg-slate-900">
|
||||
<tr>
|
||||
<th class="w-8 px-2 py-2"><input type="checkbox" class="dns-select-all rounded border-gray-300 dark:border-slate-600" data-type="MX"></th>
|
||||
<th class="px-4 py-2 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase">Priority</th>
|
||||
<th class="px-4 py-2 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase">Mail Server</th>
|
||||
<th class="px-4 py-2 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase">TTL</th>
|
||||
<th class="w-8 px-2 py-2"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200 dark:divide-slate-700">
|
||||
{% for record in dnsRecords['MX'] %}
|
||||
<tr class="hover:bg-gray-50 dark:hover:bg-slate-700">
|
||||
<td class="w-8 px-2 py-2"><input type="checkbox" class="dns-record-cb rounded border-gray-300 dark:border-slate-600" value="{{ record.id }}"></td>
|
||||
<td class="px-4 py-2">
|
||||
<span class="inline-flex items-center justify-center w-6 h-6 bg-green-100 dark:bg-green-500/10 text-green-800 dark:text-green-400 font-bold rounded-full text-xs">{{ record.priority }}</span>
|
||||
<span class="inline-flex items-center justify-center w-6 h-6 bg-green-100 dark:bg-green-500/10 text-green-800 dark:text-green-400 font-bold rounded-full text-xs">{{ record.priority }}</span>{% if record.source|default('discovered') == 'manual' %}
|
||||
<span class="ml-1 px-1 py-0.5 bg-blue-100 dark:bg-blue-500/10 text-blue-700 dark:text-blue-400 text-xs rounded font-medium" style="font-size: 9px;">manual</span>
|
||||
{% elseif record.source|default('discovered') == 'imported' %}
|
||||
<span class="ml-1 px-1 py-0.5 bg-purple-100 dark:bg-purple-500/10 text-purple-700 dark:text-purple-400 text-xs rounded font-medium" style="font-size: 9px;">imported</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="px-4 py-2 text-xs font-mono text-gray-900 dark:text-white">{{ record.value }}</td>
|
||||
<td class="px-4 py-2 text-xs text-gray-600 dark:text-slate-400">{{ record.ttl }}s</td>
|
||||
<td class="w-8 px-2 py-2">
|
||||
<form method="POST" action="/domains/{{ domain.id }}/dns-records/{{ record.id }}/delete" class="inline" onsubmit="return confirmSubmit(event, 'Delete this DNS record?')">
|
||||
{{ csrf_field()|raw }}
|
||||
<button type="submit" class="text-gray-400 hover:text-red-500 dark:text-slate-500 dark:hover:text-red-400 transition-colors" title="Delete">
|
||||
<i class="fas fa-trash-alt" style="font-size: 10px;"></i>
|
||||
</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
@@ -331,10 +453,19 @@
|
||||
<span class="ml-2 px-1.5 py-0.5 bg-purple-600 text-white text-xs font-semibold rounded">{{ dnsRecords['TXT']|length }}</span>
|
||||
</h3>
|
||||
</div>
|
||||
<div class="p-4 space-y-2">
|
||||
{% for record in dnsRecords['TXT'] %}
|
||||
<div class="bg-gray-50 dark:bg-slate-700 rounded p-2 border border-gray-200 dark:border-slate-600">
|
||||
<div class="flex items-start">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full">
|
||||
<thead class="bg-gray-50 dark:bg-slate-900">
|
||||
<tr>
|
||||
<th class="w-8 px-2 py-2"><input type="checkbox" class="dns-select-all rounded border-gray-300 dark:border-slate-600" data-type="TXT"></th>
|
||||
<th class="px-4 py-2 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase">Type</th>
|
||||
<th class="px-4 py-2 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase">Value</th>
|
||||
<th class="px-4 py-2 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase">TTL</th>
|
||||
<th class="w-8 px-2 py-2"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200 dark:divide-slate-700">
|
||||
{% for record in dnsRecords['TXT'] %}
|
||||
{% set val = record.value|lower %}
|
||||
{% if val starts with 'v=spf1' %}
|
||||
{% set txtType = 'SPF' %}
|
||||
@@ -351,11 +482,29 @@
|
||||
{% else %}
|
||||
{% set txtType = 'TXT' %}
|
||||
{% endif %}
|
||||
<span class="inline-flex items-center px-1.5 py-0.5 bg-purple-100 dark:bg-purple-500/10 text-purple-800 dark:text-purple-400 text-xs font-semibold rounded mr-2 flex-shrink-0">{{ txtType }}</span>
|
||||
<p class="flex-1 text-xs font-mono text-gray-900 dark:text-white break-all">{{ record.value }}</p>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
<tr class="hover:bg-gray-50 dark:hover:bg-slate-700">
|
||||
<td class="w-8 px-2 py-2"><input type="checkbox" class="dns-record-cb rounded border-gray-300 dark:border-slate-600" value="{{ record.id }}"></td>
|
||||
<td class="px-4 py-2">
|
||||
<span class="inline-flex items-center px-1.5 py-0.5 bg-purple-100 dark:bg-purple-500/10 text-purple-800 dark:text-purple-400 text-xs font-semibold rounded">{{ txtType }}</span>{% if record.source|default('discovered') == 'manual' %}
|
||||
<span class="ml-1 px-1 py-0.5 bg-blue-100 dark:bg-blue-500/10 text-blue-700 dark:text-blue-400 text-xs rounded font-medium" style="font-size: 9px;">manual</span>
|
||||
{% elseif record.source|default('discovered') == 'imported' %}
|
||||
<span class="ml-1 px-1 py-0.5 bg-purple-100 dark:bg-purple-500/10 text-purple-700 dark:text-purple-400 text-xs rounded font-medium" style="font-size: 9px;">imported</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="px-4 py-2 text-xs font-mono text-gray-900 dark:text-white break-all">{{ record.value }}</td>
|
||||
<td class="px-4 py-2 text-xs text-gray-600 dark:text-slate-400">{{ record.ttl }}s</td>
|
||||
<td class="w-8 px-2 py-2">
|
||||
<form method="POST" action="/domains/{{ domain.id }}/dns-records/{{ record.id }}/delete" class="inline" onsubmit="return confirmSubmit(event, 'Delete this DNS record?')">
|
||||
{{ csrf_field()|raw }}
|
||||
<button type="submit" class="text-gray-400 hover:text-red-500 dark:text-slate-500 dark:hover:text-red-400 transition-colors" title="Delete">
|
||||
<i class="fas fa-trash-alt" style="font-size: 10px;"></i>
|
||||
</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
@@ -374,11 +523,13 @@
|
||||
<table class="min-w-full">
|
||||
<thead class="bg-gray-50 dark:bg-slate-900">
|
||||
<tr>
|
||||
<th class="w-8 px-2 py-2"><input type="checkbox" class="dns-select-all rounded border-gray-300 dark:border-slate-600" data-type="NS"></th>
|
||||
<th class="px-4 py-2 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase">#</th>
|
||||
<th class="px-4 py-2 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase">Nameserver</th>
|
||||
<th class="px-4 py-2 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase">IPv4</th>
|
||||
<th class="px-4 py-2 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase">IPv6</th>
|
||||
<th class="px-4 py-2 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase">TTL</th>
|
||||
<th class="w-8 px-2 py-2"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200 dark:divide-slate-700">
|
||||
@@ -386,10 +537,15 @@
|
||||
{% set rawData = record.raw_data ? record.raw_data|from_json : null %}
|
||||
{% set nsIps = rawData ? rawData._ns_ips|default(null) : null %}
|
||||
<tr class="hover:bg-gray-50 dark:hover:bg-slate-700">
|
||||
<td class="w-8 px-2 py-2"><input type="checkbox" class="dns-record-cb rounded border-gray-300 dark:border-slate-600" value="{{ record.id }}"></td>
|
||||
<td class="px-4 py-2">
|
||||
<div class="w-6 h-6 bg-teal-500 rounded flex items-center justify-center text-white font-bold text-xs">{{ loop.index }}</div>
|
||||
</td>
|
||||
<td class="px-4 py-2 text-xs font-mono text-gray-900 dark:text-white">{{ record.value }}</td>
|
||||
<td class="px-4 py-2 text-xs font-mono text-gray-900 dark:text-white">{{ record.value }}{% if record.source|default('discovered') == 'manual' %}
|
||||
<span class="ml-1 px-1 py-0.5 bg-blue-100 dark:bg-blue-500/10 text-blue-700 dark:text-blue-400 text-xs rounded font-medium" style="font-size: 9px;">manual</span>
|
||||
{% elseif record.source|default('discovered') == 'imported' %}
|
||||
<span class="ml-1 px-1 py-0.5 bg-purple-100 dark:bg-purple-500/10 text-purple-700 dark:text-purple-400 text-xs rounded font-medium" style="font-size: 9px;">imported</span>
|
||||
{% endif %}</td>
|
||||
<td class="px-4 py-2 text-xs font-mono text-gray-600 dark:text-slate-400">
|
||||
{% if nsIps and nsIps.ipv4|default([])|length > 0 %}
|
||||
{{ nsIps.ipv4|join(', ') }}
|
||||
@@ -401,6 +557,14 @@
|
||||
{% else %}-{% endif %}
|
||||
</td>
|
||||
<td class="px-4 py-2 text-xs text-gray-600 dark:text-slate-400">{{ record.ttl }}s</td>
|
||||
<td class="w-8 px-2 py-2">
|
||||
<form method="POST" action="/domains/{{ domain.id }}/dns-records/{{ record.id }}/delete" class="inline" onsubmit="return confirmSubmit(event, 'Delete this DNS record?')">
|
||||
{{ csrf_field()|raw }}
|
||||
<button type="submit" class="text-gray-400 hover:text-red-500 dark:text-slate-500 dark:hover:text-red-400 transition-colors" title="Delete">
|
||||
<i class="fas fa-trash-alt" style="font-size: 10px;"></i>
|
||||
</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
@@ -423,24 +587,39 @@
|
||||
<table class="min-w-full">
|
||||
<thead class="bg-gray-50 dark:bg-slate-900">
|
||||
<tr>
|
||||
<th class="w-8 px-2 py-2"><input type="checkbox" class="dns-select-all rounded border-gray-300 dark:border-slate-600" data-type="SRV"></th>
|
||||
<th class="px-4 py-2 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase">Service</th>
|
||||
<th class="px-4 py-2 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase">Target</th>
|
||||
<th class="px-4 py-2 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase">Port</th>
|
||||
<th class="px-4 py-2 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase">Priority</th>
|
||||
<th class="px-4 py-2 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase">Weight</th>
|
||||
<th class="px-4 py-2 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase">TTL</th>
|
||||
<th class="w-8 px-2 py-2"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200 dark:divide-slate-700">
|
||||
{% for record in dnsRecords['SRV'] %}
|
||||
{% set rawData = record.raw_data ? record.raw_data|from_json : {} %}
|
||||
<tr class="hover:bg-gray-50 dark:hover:bg-slate-700">
|
||||
<td class="px-4 py-2 text-xs font-mono text-gray-900 dark:text-white">{{ record.host }}</td>
|
||||
<td class="w-8 px-2 py-2"><input type="checkbox" class="dns-record-cb rounded border-gray-300 dark:border-slate-600" value="{{ record.id }}"></td>
|
||||
<td class="px-4 py-2 text-xs font-mono text-gray-900 dark:text-white">{{ record.host }}{% if record.source|default('discovered') == 'manual' %}
|
||||
<span class="ml-1 px-1 py-0.5 bg-blue-100 dark:bg-blue-500/10 text-blue-700 dark:text-blue-400 text-xs rounded font-medium" style="font-size: 9px;">manual</span>
|
||||
{% elseif record.source|default('discovered') == 'imported' %}
|
||||
<span class="ml-1 px-1 py-0.5 bg-purple-100 dark:bg-purple-500/10 text-purple-700 dark:text-purple-400 text-xs rounded font-medium" style="font-size: 9px;">imported</span>
|
||||
{% endif %}</td>
|
||||
<td class="px-4 py-2 text-xs font-mono text-blue-600 dark:text-blue-400">{{ record.value }}</td>
|
||||
<td class="px-4 py-2 text-xs text-gray-900 dark:text-white font-semibold">{{ rawData.port|default('-') }}</td>
|
||||
<td class="px-4 py-2 text-xs text-gray-600 dark:text-slate-400">{{ record.priority|default('-') }}</td>
|
||||
<td class="px-4 py-2 text-xs text-gray-600 dark:text-slate-400">{{ rawData.weight|default('-') }}</td>
|
||||
<td class="px-4 py-2 text-xs text-gray-600 dark:text-slate-400">{{ record.ttl }}s</td>
|
||||
<td class="w-8 px-2 py-2">
|
||||
<form method="POST" action="/domains/{{ domain.id }}/dns-records/{{ record.id }}/delete" class="inline" onsubmit="return confirmSubmit(event, 'Delete this DNS record?')">
|
||||
{{ csrf_field()|raw }}
|
||||
<button type="submit" class="text-gray-400 hover:text-red-500 dark:text-slate-500 dark:hover:text-red-400 transition-colors" title="Delete">
|
||||
<i class="fas fa-trash-alt" style="font-size: 10px;"></i>
|
||||
</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
@@ -463,22 +642,37 @@
|
||||
<table class="min-w-full">
|
||||
<thead class="bg-gray-50 dark:bg-slate-900">
|
||||
<tr>
|
||||
<th class="w-8 px-2 py-2"><input type="checkbox" class="dns-select-all rounded border-gray-300 dark:border-slate-600" data-type="CAA"></th>
|
||||
<th class="px-4 py-2 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase">Tag</th>
|
||||
<th class="px-4 py-2 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase">Value (CA)</th>
|
||||
<th class="px-4 py-2 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase">Flags</th>
|
||||
<th class="px-4 py-2 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase">TTL</th>
|
||||
<th class="w-8 px-2 py-2"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200 dark:divide-slate-700">
|
||||
{% for record in dnsRecords['CAA'] %}
|
||||
{% set rawData = record.raw_data ? record.raw_data|from_json : {} %}
|
||||
<tr class="hover:bg-gray-50 dark:hover:bg-slate-700">
|
||||
<td class="w-8 px-2 py-2"><input type="checkbox" class="dns-record-cb rounded border-gray-300 dark:border-slate-600" value="{{ record.id }}"></td>
|
||||
<td class="px-4 py-2">
|
||||
<span class="inline-flex items-center px-1.5 py-0.5 bg-orange-100 dark:bg-orange-500/10 text-orange-800 dark:text-orange-400 text-xs font-semibold rounded">{{ rawData.tag|default('-') }}</span>
|
||||
<span class="inline-flex items-center px-1.5 py-0.5 bg-orange-100 dark:bg-orange-500/10 text-orange-800 dark:text-orange-400 text-xs font-semibold rounded">{{ rawData.tag|default('-') }}</span>{% if record.source|default('discovered') == 'manual' %}
|
||||
<span class="ml-1 px-1 py-0.5 bg-blue-100 dark:bg-blue-500/10 text-blue-700 dark:text-blue-400 text-xs rounded font-medium" style="font-size: 9px;">manual</span>
|
||||
{% elseif record.source|default('discovered') == 'imported' %}
|
||||
<span class="ml-1 px-1 py-0.5 bg-purple-100 dark:bg-purple-500/10 text-purple-700 dark:text-purple-400 text-xs rounded font-medium" style="font-size: 9px;">imported</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="px-4 py-2 text-xs font-mono text-gray-900 dark:text-white">{{ rawData.value|default(record.value) }}</td>
|
||||
<td class="px-4 py-2 text-xs text-gray-600 dark:text-slate-400">{{ rawData.flags|default('0') }}</td>
|
||||
<td class="px-4 py-2 text-xs text-gray-600 dark:text-slate-400">{{ record.ttl }}s</td>
|
||||
<td class="w-8 px-2 py-2">
|
||||
<form method="POST" action="/domains/{{ domain.id }}/dns-records/{{ record.id }}/delete" class="inline" onsubmit="return confirmSubmit(event, 'Delete this DNS record?')">
|
||||
{{ csrf_field()|raw }}
|
||||
<button type="submit" class="text-gray-400 hover:text-red-500 dark:text-slate-500 dark:hover:text-red-400 transition-colors" title="Delete">
|
||||
<i class="fas fa-trash-alt" style="font-size: 10px;"></i>
|
||||
</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
@@ -491,6 +685,88 @@
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
{# ===== Add Record Modal ===== #}
|
||||
{% if domain %}
|
||||
<div id="addDnsRecordModal" class="hidden fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center p-4">
|
||||
<div class="bg-white dark:bg-slate-800 rounded-lg shadow-xl w-full max-w-md">
|
||||
<div class="px-6 py-4 border-b border-gray-200 dark:border-slate-700 flex items-center justify-between">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
<i class="fas fa-plus text-primary mr-2"></i>Add DNS Record
|
||||
</h3>
|
||||
<button onclick="document.getElementById('addDnsRecordModal').classList.add('hidden')"
|
||||
class="text-gray-400 dark:text-slate-500 hover:text-gray-600 dark:hover:text-slate-300">
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
<form method="POST" action="/domains/{{ domain.id }}/dns-records" id="addDnsRecordForm">
|
||||
{{ csrf_field()|raw }}
|
||||
<div class="p-6 space-y-4">
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-slate-300 mb-1.5">Type</label>
|
||||
<select name="record_type" id="dnsRecordType" required class="w-full px-3 py-2 border border-gray-300 dark:border-slate-600 rounded-lg text-sm bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-primary focus:border-transparent">
|
||||
<option value="A">A (IPv4)</option>
|
||||
<option value="AAAA">AAAA (IPv6)</option>
|
||||
<option value="CNAME">CNAME (Alias)</option>
|
||||
<option value="MX">MX (Mail)</option>
|
||||
<option value="TXT">TXT (Text)</option>
|
||||
<option value="NS">NS (Nameserver)</option>
|
||||
<option value="SRV">SRV (Service)</option>
|
||||
<option value="CAA">CAA (CA Auth)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-slate-300 mb-1.5">Host</label>
|
||||
<input type="text" name="host" id="dnsRecordHost" value="@" required class="w-full px-3 py-2 border border-gray-300 dark:border-slate-600 rounded-lg text-sm bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-primary focus:border-transparent" placeholder="@ or subdomain">
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-slate-300 mb-1.5" id="dnsValueLabel">Value</label>
|
||||
<input type="text" name="value" id="dnsRecordValue" required class="w-full px-3 py-2 border border-gray-300 dark:border-slate-600 rounded-lg text-sm bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-primary focus:border-transparent" placeholder="IPv4 address (e.g. 1.2.3.4)">
|
||||
<p class="mt-1 text-xs text-gray-400 dark:text-slate-500" id="dnsValueHint">Enter the IPv4 address this record points to.</p>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-slate-300 mb-1.5">TTL <span class="text-gray-400 font-normal">(seconds)</span></label>
|
||||
<input type="number" name="ttl" id="dnsRecordTtl" min="0" value="3600" class="w-full px-3 py-2 border border-gray-300 dark:border-slate-600 rounded-lg text-sm bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-primary focus:border-transparent" placeholder="3600">
|
||||
</div>
|
||||
<div id="dnsPriorityWrap">
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-slate-300 mb-1.5">Priority</label>
|
||||
<input type="number" name="priority" id="dnsRecordPriority" min="0" class="w-full px-3 py-2 border border-gray-300 dark:border-slate-600 rounded-lg text-sm bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-primary focus:border-transparent" placeholder="10">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="px-6 py-4 border-t border-gray-200 dark:border-slate-700 bg-gray-50 dark:bg-slate-900 flex justify-end gap-2 rounded-b-lg">
|
||||
<button type="button" onclick="document.getElementById('addDnsRecordModal').classList.add('hidden')"
|
||||
class="px-4 py-2 border border-gray-300 dark:border-slate-600 text-gray-700 dark:text-slate-300 rounded-lg text-sm font-medium hover:bg-gray-50 dark:hover:bg-slate-700 transition-colors">
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit" class="px-4 py-2 bg-primary text-white rounded-lg text-sm font-medium hover:bg-primary-dark transition-colors">
|
||||
<i class="fas fa-plus mr-1.5"></i>Add Record
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# ===== Import Zone Modal (shared partial) ===== #}
|
||||
{% include 'partials/import-modal.twig' with {
|
||||
prefix: 'dnsZone',
|
||||
title: 'Import DNS Zone File',
|
||||
action: '/domains/' ~ domain.id ~ '/dns-import',
|
||||
accept: '.txt,.zone,.db,.bind',
|
||||
file_hint: 'BIND zone file (.txt, .zone)',
|
||||
input_name: 'zone_file',
|
||||
format_html: '<p class="text-xs text-gray-600 dark:text-slate-400">Standard BIND zone file format:</p><p class="text-xs text-gray-600 dark:text-slate-400 mt-0.5 font-mono">@ IN A 1.2.3.4</p><p class="text-xs text-gray-600 dark:text-slate-400 font-mono">www IN CNAME example.com.</p><p class="text-xs text-gray-500 dark:text-slate-400 mt-1">Duplicate records will be skipped. Imported records are tagged as "imported".</p>',
|
||||
submit_label: 'Import Zone'
|
||||
} %}
|
||||
|
||||
{# ===== Bulk Delete Form (hidden, submitted via JS) ===== #}
|
||||
<form id="dnsBulkDeleteForm" method="POST" action="/domains/{{ domain.id }}/dns-records/bulk-delete" class="hidden">
|
||||
{{ csrf_field()|raw }}
|
||||
</form>
|
||||
{% endif %}
|
||||
|
||||
<script>
|
||||
function handleDnsRefresh(form) {
|
||||
var btn = form.querySelector('.dns-refresh-btn');
|
||||
@@ -504,4 +780,137 @@ function handleDnsRefresh(form) {
|
||||
if (label) label.textContent = 'Scanning DNS...';
|
||||
return true;
|
||||
}
|
||||
|
||||
(function() {
|
||||
// Close discover dropdown when clicking outside
|
||||
document.addEventListener('click', function(e) {
|
||||
var dropdown = document.getElementById('discoverDropdown');
|
||||
var menu = document.getElementById('discoverMenu');
|
||||
if (dropdown && menu && !dropdown.contains(e.target)) {
|
||||
menu.classList.add('hidden');
|
||||
}
|
||||
});
|
||||
|
||||
// Checkbox select-all
|
||||
document.querySelectorAll('.dns-select-all').forEach(function(selectAll) {
|
||||
selectAll.addEventListener('change', function() {
|
||||
var table = this.closest('table');
|
||||
table.querySelectorAll('.dns-record-cb').forEach(function(cb) {
|
||||
cb.checked = selectAll.checked;
|
||||
});
|
||||
updateBulkDeleteBtn();
|
||||
});
|
||||
});
|
||||
|
||||
// Individual checkbox change
|
||||
document.addEventListener('change', function(e) {
|
||||
if (e.target.classList.contains('dns-record-cb')) {
|
||||
updateBulkDeleteBtn();
|
||||
}
|
||||
});
|
||||
|
||||
// Close modals on Escape
|
||||
document.addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Escape') {
|
||||
var modals = ['addDnsRecordModal', 'dnsZoneImportModal'];
|
||||
modals.forEach(function(id) {
|
||||
var el = document.getElementById(id);
|
||||
if (el && !el.classList.contains('hidden')) {
|
||||
el.classList.add('hidden');
|
||||
}
|
||||
});
|
||||
var menu = document.getElementById('discoverMenu');
|
||||
if (menu) menu.classList.add('hidden');
|
||||
}
|
||||
});
|
||||
})();
|
||||
|
||||
function updateBulkDeleteBtn() {
|
||||
var checked = document.querySelectorAll('.dns-record-cb:checked');
|
||||
var btn = document.getElementById('dnsBulkDeleteBtn');
|
||||
var count = document.getElementById('dnsSelectedCount');
|
||||
if (btn) {
|
||||
if (checked.length > 0) {
|
||||
btn.classList.remove('hidden');
|
||||
if (count) count.textContent = checked.length;
|
||||
} else {
|
||||
btn.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function submitDnsBulkDelete() {
|
||||
var checked = document.querySelectorAll('.dns-record-cb:checked');
|
||||
if (checked.length === 0) return;
|
||||
var ok = await confirmAction({ message: 'Delete ' + checked.length + ' selected DNS record(s)?' });
|
||||
if (!ok) return;
|
||||
|
||||
var form = document.getElementById('dnsBulkDeleteForm');
|
||||
// Remove any previous hidden inputs
|
||||
form.querySelectorAll('input[name="record_ids[]"]').forEach(function(el) { el.remove(); });
|
||||
checked.forEach(function(cb) {
|
||||
var input = document.createElement('input');
|
||||
input.type = 'hidden';
|
||||
input.name = 'record_ids[]';
|
||||
input.value = cb.value;
|
||||
form.appendChild(input);
|
||||
});
|
||||
form.submit();
|
||||
}
|
||||
|
||||
(function() {
|
||||
var typeSelect = document.getElementById('dnsRecordType');
|
||||
if (!typeSelect) return;
|
||||
|
||||
var valueInput = document.getElementById('dnsRecordValue');
|
||||
var valueLabel = document.getElementById('dnsValueLabel');
|
||||
var valueHint = document.getElementById('dnsValueHint');
|
||||
var hostInput = document.getElementById('dnsRecordHost');
|
||||
var prioWrap = document.getElementById('dnsPriorityWrap');
|
||||
var prioInput = document.getElementById('dnsRecordPriority');
|
||||
|
||||
var typeMeta = {
|
||||
A: { label: 'IPv4 Address', placeholder: '1.2.3.4', hint: 'Enter the IPv4 address this record points to.', priority: false },
|
||||
AAAA: { label: 'IPv6 Address', placeholder: '2001:db8::1', hint: 'Enter the full IPv6 address.', priority: false },
|
||||
CNAME: { label: 'Target Hostname', placeholder: 'example.com', hint: 'The canonical hostname this alias resolves to (no trailing dot needed).', priority: false, host: 'subdomain' },
|
||||
MX: { label: 'Mail Server', placeholder: 'mail.example.com', hint: 'Hostname of the mail server. Set priority (lower = higher preference).', priority: true, prioDefault: '10' },
|
||||
TXT: { label: 'Text Value', placeholder: 'v=spf1 include:_spf.google.com ~all', hint: 'SPF, DKIM, verification tokens, or any text value.', priority: false },
|
||||
NS: { label: 'Nameserver', placeholder: 'ns1.example.com', hint: 'Hostname of the authoritative nameserver.', priority: false },
|
||||
SRV: { label: 'Target', placeholder: 'sipserver.example.com', hint: 'Target hostname. Host should be _service._proto format. Set priority & use value format: weight port target.', priority: true, prioDefault: '0', host: '_sip._tcp' },
|
||||
CAA: { label: 'Value', placeholder: '0 issue "letsencrypt.org"', hint: 'Format: flags tag value (e.g. 0 issue "letsencrypt.org").', priority: false }
|
||||
};
|
||||
|
||||
function updateFieldsForType() {
|
||||
var t = typeSelect.value;
|
||||
var meta = typeMeta[t] || typeMeta['A'];
|
||||
|
||||
valueLabel.textContent = meta.label;
|
||||
valueInput.placeholder = meta.placeholder;
|
||||
valueHint.textContent = meta.hint;
|
||||
|
||||
if (meta.priority) {
|
||||
prioWrap.classList.remove('hidden');
|
||||
if (prioInput && !prioInput.value) prioInput.placeholder = meta.prioDefault || '10';
|
||||
} else {
|
||||
prioWrap.classList.add('hidden');
|
||||
if (prioInput) prioInput.value = '';
|
||||
}
|
||||
|
||||
if (meta.host) {
|
||||
hostInput.placeholder = meta.host;
|
||||
} else {
|
||||
hostInput.placeholder = '@ or subdomain';
|
||||
}
|
||||
}
|
||||
|
||||
typeSelect.addEventListener('change', updateFieldsForType);
|
||||
updateFieldsForType();
|
||||
|
||||
document.getElementById('addDnsRecordForm').addEventListener('submit', function() {
|
||||
var ttlInput = document.getElementById('dnsRecordTtl');
|
||||
if (ttlInput && (!ttlInput.value || ttlInput.value.trim() === '')) {
|
||||
ttlInput.value = '3600';
|
||||
}
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
|
||||
@@ -319,7 +319,7 @@
|
||||
Check Now
|
||||
</button>
|
||||
</form>
|
||||
<form method="POST" action="/domains/{{ domain.id }}/ssl/{{ certificate.id }}/delete" class="inline" onsubmit="return confirm('Remove SSL monitoring for {{ certificate.display_target }}?');">
|
||||
<form method="POST" action="/domains/{{ domain.id }}/ssl/{{ certificate.id }}/delete" class="inline" onsubmit="return confirmSubmit(event, 'Remove SSL monitoring for {{ certificate.display_target }}?')">
|
||||
{{ csrf_field()|raw }}
|
||||
<button type="submit" class="inline-flex items-center px-2 py-1 bg-red-600 text-white text-xs rounded hover:bg-red-700 transition-colors font-medium">
|
||||
<i class="fas fa-trash mr-1" style="font-size: 9px;"></i>
|
||||
@@ -433,14 +433,15 @@ function clearSSLSelection() {
|
||||
updateSSLBulkActions();
|
||||
}
|
||||
|
||||
function submitBulkSslAction(action) {
|
||||
async function submitBulkSslAction(action) {
|
||||
const selectedIds = getSelectedSSLIds();
|
||||
if (selectedIds.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (action === 'delete' && !window.confirm(`Remove SSL monitoring for ${selectedIds.length} endpoint(s)?`)) {
|
||||
return;
|
||||
if (action === 'delete') {
|
||||
var ok = await confirmAction({ message: 'Remove SSL monitoring for ' + selectedIds.length + ' endpoint(s)?' });
|
||||
if (!ok) return;
|
||||
}
|
||||
|
||||
const input = document.getElementById(`ssl-bulk-${action}-ids`);
|
||||
|
||||
@@ -74,7 +74,7 @@
|
||||
<i class="fas fa-edit mr-1.5"></i>
|
||||
Edit
|
||||
</a>
|
||||
<form method="POST" action="/domains/{{ domain.id }}/delete" onsubmit="return confirm('Delete this domain?')" class="inline">
|
||||
<form method="POST" action="/domains/{{ domain.id }}/delete" onsubmit="return confirmSubmit(event, 'Delete this domain?')" class="inline">
|
||||
{{ csrf_field()|raw }}
|
||||
<button type="submit" class="inline-flex items-center justify-center px-3 py-2 bg-red-600 text-white text-xs rounded-lg hover:bg-red-700 transition-colors font-medium min-w-[80px] h-[32px]">
|
||||
<i class="fas fa-trash mr-1.5"></i>
|
||||
|
||||
@@ -61,7 +61,7 @@
|
||||
<i class="fas fa-edit mr-1.5"></i>
|
||||
Edit
|
||||
</a>
|
||||
<form method="POST" action="/domains/{{ domain.id }}/delete" onsubmit="return confirm('Delete this domain?')" class="inline">
|
||||
<form method="POST" action="/domains/{{ domain.id }}/delete" onsubmit="return confirmSubmit(event, 'Delete this domain?')" class="inline">
|
||||
{{ csrf_field() }}
|
||||
<button type="submit" class="inline-flex items-center justify-center px-3 py-2 bg-red-600 text-white text-xs rounded-lg hover:bg-red-700 transition-colors font-medium min-w-[80px] h-[32px]">
|
||||
<i class="fas fa-trash mr-1.5"></i>
|
||||
|
||||
@@ -465,8 +465,9 @@ function submitResolution() {
|
||||
form.submit();
|
||||
}
|
||||
|
||||
function deleteError() {
|
||||
if (!confirm('Are you sure you want to delete this error and all its occurrences? This action cannot be undone.')) return;
|
||||
async function deleteError() {
|
||||
var ok = await confirmAction({ message: 'Are you sure you want to delete this error and all its occurrences? This action cannot be undone.' });
|
||||
if (!ok) return;
|
||||
const form = document.createElement('form');
|
||||
form.method = 'POST';
|
||||
form.action = '/errors/{{ error.error_id }}/delete';
|
||||
|
||||
@@ -412,8 +412,9 @@ function submitResolution() {
|
||||
form.submit();
|
||||
}
|
||||
|
||||
function deleteError(errorId) {
|
||||
if (!confirm('Are you sure you want to delete this error and all its occurrences? This action cannot be undone.')) return;
|
||||
async function deleteError(errorId) {
|
||||
var ok = await confirmAction({ message: 'Are you sure you want to delete this error and all its occurrences? This action cannot be undone.' });
|
||||
if (!ok) return;
|
||||
const form = document.createElement('form');
|
||||
form.method = 'POST';
|
||||
form.action = '/errors/' + errorId + '/delete';
|
||||
@@ -457,10 +458,11 @@ function clearSelection() {
|
||||
updateBulkActions();
|
||||
}
|
||||
|
||||
function bulkDelete() {
|
||||
async function bulkDelete() {
|
||||
const errorIds = getSelectedErrorIds();
|
||||
if (errorIds.length === 0) { alert('Please select at least one error to delete'); return; }
|
||||
if (!confirm(`Are you sure you want to delete ${errorIds.length} error(s) and all their occurrences? This action cannot be undone.`)) return;
|
||||
var ok = await confirmAction({ message: 'Are you sure you want to delete ' + errorIds.length + ' error(s) and all their occurrences? This action cannot be undone.' });
|
||||
if (!ok) return;
|
||||
const form = document.createElement('form');
|
||||
form.method = 'POST';
|
||||
form.action = '/errors/bulk-delete';
|
||||
|
||||
@@ -133,7 +133,7 @@
|
||||
{{ csrf_field() }}
|
||||
<button type="submit"
|
||||
class="w-full px-3 py-2 bg-red-50 dark:bg-red-500/10 text-red-700 dark:text-red-400 rounded text-center text-sm hover:bg-red-100 dark:hover:bg-red-500/20 transition-colors duration-150"
|
||||
onclick="return confirm('Delete this channel?')">
|
||||
onclick="return confirmClick(event, 'Delete this channel?')">
|
||||
<i class="fas fa-trash mr-1"></i>
|
||||
Delete
|
||||
</button>
|
||||
|
||||
@@ -138,7 +138,7 @@
|
||||
<i class="fas fa-exchange-alt"></i>
|
||||
</button>
|
||||
{% endif %}
|
||||
<form method="POST" action="/groups/{{ group.id }}/delete" class="inline" onsubmit="return confirm('Are you sure? Domains will be unassigned from this group.')">
|
||||
<form method="POST" action="/groups/{{ group.id }}/delete" class="inline" onsubmit="return confirmSubmit(event, 'Are you sure? Domains will be unassigned from this group.')">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<button type="submit" class="text-red-600 hover:text-red-800" title="Delete"
|
||||
aria-label="Delete group {{ group.name }}">
|
||||
@@ -184,7 +184,7 @@
|
||||
<a href="/groups/{{ group.id }}/edit" class="flex-1 px-3 py-1.5 bg-blue-50 dark:bg-blue-500/10 text-blue-600 dark:text-blue-400 rounded text-center text-sm hover:bg-blue-100 dark:hover:bg-blue-500/20 transition-colors">
|
||||
<i class="fas fa-cog mr-1"></i> Manage
|
||||
</a>
|
||||
<form method="POST" action="/groups/{{ group.id }}/delete" class="flex-1" onsubmit="return confirm('Are you sure? Domains will be unassigned from this group.')">
|
||||
<form method="POST" action="/groups/{{ group.id }}/delete" class="flex-1" onsubmit="return confirmSubmit(event, 'Are you sure? Domains will be unassigned from this group.')">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<button type="submit" class="w-full px-3 py-1.5 bg-red-50 dark:bg-red-500/10 text-red-600 dark:text-red-400 rounded text-center text-sm hover:bg-red-100 dark:hover:bg-red-500/20 transition-colors">
|
||||
<i class="fas fa-trash mr-1"></i> Delete
|
||||
@@ -252,7 +252,7 @@ function getSelectedGroupIds() {
|
||||
return Array.from(checkboxes).map(cb => cb.value);
|
||||
}
|
||||
|
||||
function bulkDelete() {
|
||||
async function bulkDelete() {
|
||||
const groupIds = getSelectedGroupIds();
|
||||
|
||||
if (groupIds.length === 0) {
|
||||
@@ -260,9 +260,8 @@ function bulkDelete() {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!confirm(`Are you sure you want to delete ${groupIds.length} group(s)? Domains will be unassigned from these groups.`)) {
|
||||
return;
|
||||
}
|
||||
var ok = await confirmAction({ message: 'Are you sure you want to delete ' + groupIds.length + ' group(s)? Domains will be unassigned from these groups.' });
|
||||
if (!ok) return;
|
||||
|
||||
const form = document.createElement('form');
|
||||
form.method = 'POST';
|
||||
@@ -285,49 +284,15 @@ function bulkDelete() {
|
||||
}
|
||||
|
||||
function transferGroup(groupId, groupName) {
|
||||
const users = {{ users|default([])|json_encode|raw }};
|
||||
|
||||
if (users.length === 0) {
|
||||
alert('No users available for transfer');
|
||||
return;
|
||||
}
|
||||
|
||||
const userOptions = users.map(user =>
|
||||
`<option value="${user.id}">${user.username} (${user.full_name || 'No name'})</option>`
|
||||
).join('');
|
||||
|
||||
const modal = document.createElement('div');
|
||||
modal.className = 'fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4';
|
||||
modal.innerHTML = `
|
||||
<div class="bg-white dark:bg-slate-800 rounded-lg shadow-xl w-full max-w-md p-6">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-1">Transfer Group</h3>
|
||||
<p class="text-sm text-gray-600 dark:text-slate-400 mb-4">Transfer group "${groupName}" to another user.</p>
|
||||
|
||||
<form method="POST" action="/groups/transfer">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<input type="hidden" name="group_id" value="${groupId}">
|
||||
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-slate-300 mb-2">Transfer to User</label>
|
||||
<select name="target_user_id" required class="w-full px-3 py-2 border border-gray-300 dark:border-slate-600 rounded-lg focus:ring-2 focus:ring-primary focus:border-primary text-sm bg-white dark:bg-slate-700 text-gray-900 dark:text-white">
|
||||
<option value="">Select User</option>
|
||||
${userOptions}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-3">
|
||||
<button type="button" onclick="this.closest('.fixed').remove()" class="px-4 py-2 border border-gray-300 dark:border-slate-600 rounded-lg hover:bg-gray-50 dark:hover:bg-slate-700 text-sm font-medium text-gray-700 dark:text-slate-300">
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit" class="px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary-dark text-sm font-medium">
|
||||
Transfer
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
`;
|
||||
|
||||
document.body.appendChild(modal);
|
||||
const esc = (s) => String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');
|
||||
openTransferModal({
|
||||
title: 'Transfer Group',
|
||||
description: 'Transfer group <strong>' + esc(groupName) + '</strong> to another user.',
|
||||
action: '/groups/transfer',
|
||||
fields: { group_id: groupId },
|
||||
users: {{ users|default([])|json_encode|raw }},
|
||||
csrfToken: '{{ csrf_token() }}'
|
||||
});
|
||||
}
|
||||
|
||||
function bulkTransfer() {
|
||||
@@ -336,52 +301,15 @@ function bulkTransfer() {
|
||||
alert('Please select groups to transfer');
|
||||
return;
|
||||
}
|
||||
|
||||
const users = {{ users|default([])|json_encode|raw }};
|
||||
|
||||
if (users.length === 0) {
|
||||
alert('No users available for transfer');
|
||||
return;
|
||||
}
|
||||
|
||||
const userOptions = users.map(user =>
|
||||
`<option value="${user.id}">${user.username} (${user.full_name || 'No name'})</option>`
|
||||
).join('');
|
||||
|
||||
const modal = document.createElement('div');
|
||||
modal.className = 'fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4';
|
||||
modal.innerHTML = `
|
||||
<div class="bg-white dark:bg-slate-800 rounded-lg shadow-xl w-full max-w-md p-6">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-1">Transfer Groups</h3>
|
||||
<p class="text-sm text-gray-600 dark:text-slate-400 mb-4">Transfer ${groupIds.length} selected group(s) to another user.</p>
|
||||
|
||||
<form method="POST" action="/groups/bulk-transfer">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
${groupIds.map(id =>
|
||||
`<input type="hidden" name="group_ids[]" value="${id}">`
|
||||
).join('')}
|
||||
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-slate-300 mb-2">Transfer to User</label>
|
||||
<select name="target_user_id" required class="w-full px-3 py-2 border border-gray-300 dark:border-slate-600 rounded-lg focus:ring-2 focus:ring-primary focus:border-primary text-sm bg-white dark:bg-slate-700 text-gray-900 dark:text-white">
|
||||
<option value="">Select User</option>
|
||||
${userOptions}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-3">
|
||||
<button type="button" onclick="this.closest('.fixed').remove()" class="px-4 py-2 border border-gray-300 dark:border-slate-600 rounded-lg hover:bg-gray-50 dark:hover:bg-slate-700 text-sm font-medium text-gray-700 dark:text-slate-300">
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit" class="px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary-dark text-sm font-medium">
|
||||
Transfer All
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
`;
|
||||
|
||||
document.body.appendChild(modal);
|
||||
openTransferModal({
|
||||
title: 'Transfer Groups',
|
||||
description: 'Transfer ' + groupIds.length + ' selected group(s) to another user.',
|
||||
action: '/groups/bulk-transfer',
|
||||
fields: { 'group_ids[]': groupIds },
|
||||
submitText: 'Transfer All',
|
||||
users: {{ users|default([])|json_encode|raw }},
|
||||
csrfToken: '{{ csrf_token() }}'
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener('click', function(e) {
|
||||
@@ -398,140 +326,11 @@ document.getElementById('groupImportModal')?.addEventListener('click', function(
|
||||
});
|
||||
</script>
|
||||
|
||||
{# Import Modal #}
|
||||
<div id="groupImportModal" class="hidden fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center p-4">
|
||||
<div class="bg-white dark:bg-slate-800 rounded-lg shadow-xl w-full max-w-md">
|
||||
<div class="px-6 py-4 border-b border-gray-200 dark:border-slate-700 flex items-center justify-between">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
<i class="fas fa-upload text-primary mr-2"></i>Import Notification Groups
|
||||
</h3>
|
||||
<button onclick="document.getElementById('groupImportModal').classList.add('hidden')" class="text-gray-400 dark:text-slate-500 hover:text-gray-600 dark:hover:text-slate-300">
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
<form method="POST" action="/groups/import" enctype="multipart/form-data" id="groupImportForm">
|
||||
{{ csrf_field() }}
|
||||
<div class="p-6 space-y-4">
|
||||
{# Drag & Drop Zone #}
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-slate-300 mb-1.5">Select File</label>
|
||||
<div id="groupDropzone" class="relative border-2 border-dashed border-gray-300 dark:border-slate-600 rounded-lg p-6 text-center cursor-pointer transition-all hover:border-primary hover:bg-gray-50 dark:hover:bg-slate-700">
|
||||
<input type="file" name="import_file" accept=".csv,.json" required id="groupFileInput"
|
||||
class="absolute inset-0 w-full h-full opacity-0 cursor-pointer z-10">
|
||||
<div id="groupDropzoneContent">
|
||||
<i class="fas fa-cloud-upload-alt text-3xl text-gray-400 dark:text-slate-500 mb-2"></i>
|
||||
<p class="text-sm text-gray-600 dark:text-slate-400 font-medium">Drag & drop your file here</p>
|
||||
<p class="text-xs text-gray-400 dark:text-slate-500 my-1">or</p>
|
||||
<span class="inline-flex items-center px-3 py-1.5 bg-primary text-white text-xs rounded-lg font-medium">
|
||||
<i class="fas fa-folder-open mr-1.5"></i>Browse Files
|
||||
</span>
|
||||
<p class="mt-2.5 text-xs text-gray-400 dark:text-slate-500">CSV, JSON · Max {{ max_upload_size() }}</p>
|
||||
</div>
|
||||
<div id="groupDropzoneFile" class="hidden">
|
||||
<i class="fas fa-file-alt text-2xl text-primary mb-1.5"></i>
|
||||
<p class="text-sm font-medium text-gray-700 dark:text-slate-300" id="groupFileName"></p>
|
||||
<p class="text-xs text-gray-400 dark:text-slate-500" id="groupFileSize"></p>
|
||||
<button type="button" id="groupFileRemove" class="mt-1.5 text-xs text-red-500 hover:text-red-700 font-medium">
|
||||
<i class="fas fa-trash-alt mr-1"></i>Remove
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-blue-50 dark:bg-blue-500/10 border border-blue-200 dark:border-blue-500/30 rounded-lg p-3">
|
||||
<p class="text-xs text-gray-700 dark:text-slate-300 font-medium mb-1"><i class="fas fa-info-circle text-blue-500 mr-1"></i> Expected Format</p>
|
||||
<p class="text-xs text-gray-600 dark:text-slate-400">CSV: <code class="bg-white dark:bg-slate-700 px-1 rounded">group_name, group_description, channel_type, channel_config, is_active</code></p>
|
||||
<p class="text-xs text-gray-600 dark:text-slate-400 mt-0.5">JSON: array of group objects with nested channels array</p>
|
||||
<p class="text-xs text-gray-500 dark:text-slate-400 mt-1.5"><i class="fas fa-exclamation-triangle text-amber-500 mr-1"></i>Channels with masked secrets will be imported as <strong>disabled</strong>. Update the credentials and enable them manually.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="px-6 py-4 border-t border-gray-200 dark:border-slate-700 bg-gray-50 dark:bg-slate-900 flex justify-end gap-2 rounded-b-lg">
|
||||
<button type="button" onclick="document.getElementById('groupImportModal').classList.add('hidden')" class="px-4 py-2 border border-gray-300 dark:border-slate-600 text-gray-700 dark:text-slate-300 rounded-lg text-sm font-medium hover:bg-gray-50 dark:hover:bg-slate-700 transition-colors">
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit" id="groupImportBtn" class="px-4 py-2 bg-primary text-white rounded-lg text-sm font-medium hover:bg-primary-dark transition-colors">
|
||||
<i class="fas fa-upload mr-1.5"></i>Import Groups
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function() {
|
||||
const dropzone = document.getElementById('groupDropzone');
|
||||
const fileInput = document.getElementById('groupFileInput');
|
||||
const content = document.getElementById('groupDropzoneContent');
|
||||
const fileInfo = document.getElementById('groupDropzoneFile');
|
||||
const fileName = document.getElementById('groupFileName');
|
||||
const fileSize = document.getElementById('groupFileSize');
|
||||
const removeBtn = document.getElementById('groupFileRemove');
|
||||
const form = document.getElementById('groupImportForm');
|
||||
const submitBtn = document.getElementById('groupImportBtn');
|
||||
|
||||
function formatSize(bytes) {
|
||||
if (bytes >= 1048576) return (bytes / 1048576).toFixed(1) + ' MB';
|
||||
if (bytes >= 1024) return (bytes / 1024).toFixed(1) + ' KB';
|
||||
return bytes + ' B';
|
||||
}
|
||||
|
||||
function showFile(file) {
|
||||
fileName.textContent = file.name;
|
||||
fileSize.textContent = formatSize(file.size);
|
||||
content.classList.add('hidden');
|
||||
fileInfo.classList.remove('hidden');
|
||||
dropzone.classList.remove('border-gray-300');
|
||||
dropzone.classList.add('border-primary', 'bg-primary/5');
|
||||
}
|
||||
|
||||
function resetDropzone() {
|
||||
fileInput.value = '';
|
||||
content.classList.remove('hidden');
|
||||
fileInfo.classList.add('hidden');
|
||||
dropzone.classList.add('border-gray-300');
|
||||
dropzone.classList.remove('border-primary', 'bg-primary/5');
|
||||
}
|
||||
|
||||
fileInput.addEventListener('change', function() {
|
||||
if (this.files.length) showFile(this.files[0]);
|
||||
});
|
||||
|
||||
removeBtn.addEventListener('click', function(e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
resetDropzone();
|
||||
});
|
||||
|
||||
['dragenter', 'dragover'].forEach(evt => {
|
||||
dropzone.addEventListener(evt, function(e) {
|
||||
e.preventDefault();
|
||||
dropzone.classList.add('border-primary', 'bg-primary/5');
|
||||
dropzone.classList.remove('border-gray-300');
|
||||
});
|
||||
});
|
||||
|
||||
['dragleave', 'drop'].forEach(evt => {
|
||||
dropzone.addEventListener(evt, function(e) {
|
||||
e.preventDefault();
|
||||
if (!fileInput.files.length) {
|
||||
dropzone.classList.remove('border-primary', 'bg-primary/5');
|
||||
dropzone.classList.add('border-gray-300');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
dropzone.addEventListener('drop', function(e) {
|
||||
e.preventDefault();
|
||||
const files = e.dataTransfer.files;
|
||||
if (files.length) {
|
||||
fileInput.files = files;
|
||||
showFile(files[0]);
|
||||
}
|
||||
});
|
||||
|
||||
form.addEventListener('submit', function() {
|
||||
submitBtn.disabled = true;
|
||||
submitBtn.innerHTML = '<i class="fas fa-spinner fa-spin mr-1.5"></i>Importing...';
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
{% include 'partials/import-modal.twig' with {
|
||||
prefix: 'group',
|
||||
title: 'Import Notification Groups',
|
||||
action: '/groups/import',
|
||||
format_html: '<p class="text-xs text-gray-600 dark:text-slate-400">CSV: <code class="bg-white dark:bg-slate-700 px-1 rounded">group_name, group_description, channel_type, channel_config, is_active</code></p><p class="text-xs text-gray-600 dark:text-slate-400 mt-0.5">JSON: array of group objects with nested channels array</p><p class="text-xs text-gray-500 dark:text-slate-400 mt-1.5"><i class="fas fa-exclamation-triangle text-amber-500 mr-1"></i>Channels with masked secrets will be imported as <strong>disabled</strong>. Update the credentials and enable them manually.</p>'
|
||||
} %}
|
||||
{% include 'partials/transfer-modal.twig' %}
|
||||
{% endblock %}
|
||||
|
||||
@@ -372,6 +372,8 @@
|
||||
{% endif %}
|
||||
|
||||
{% block scripts %}{% endblock %}
|
||||
|
||||
{% include 'partials/confirm-modal.twig' %}
|
||||
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -310,7 +310,7 @@
|
||||
<i class="fas fa-check text-xs"></i>
|
||||
</a>
|
||||
{% endif %}
|
||||
<form method="POST" action="/notifications/{{ notification.id }}/delete" class="inline" onsubmit="return confirm('Delete this notification?')">
|
||||
<form method="POST" action="/notifications/{{ notification.id }}/delete" class="inline" onsubmit="return confirmSubmit(event, 'Delete this notification?')">
|
||||
{{ csrf_field() }}
|
||||
<button type="submit" class="w-7 h-7 flex items-center justify-center text-gray-400 dark:text-slate-500 hover:text-red-600 hover:bg-red-50 dark:hover:bg-red-500/10 rounded transition-colors" title="Delete">
|
||||
<i class="fas fa-times text-xs"></i>
|
||||
@@ -407,16 +407,14 @@
|
||||
</form>
|
||||
|
||||
<script>
|
||||
function markAllAsRead() {
|
||||
if (confirm('Mark all notifications as read?')) {
|
||||
window.location.href = '/notifications/mark-all-read';
|
||||
}
|
||||
async function markAllAsRead() {
|
||||
var ok = await confirmAction({ message: 'Mark all notifications as read?', title: 'Mark All Read', icon: 'fa-check-double text-primary', confirmText: 'Mark Read', confirmClass: 'bg-primary hover:bg-primary-dark' });
|
||||
if (ok) window.location.href = '/notifications/mark-all-read';
|
||||
}
|
||||
|
||||
function clearAll() {
|
||||
if (confirm('Clear all notifications? This action cannot be undone.')) {
|
||||
document.getElementById('clearAllForm').submit();
|
||||
}
|
||||
async function clearAll() {
|
||||
var ok = await confirmAction({ message: 'Clear all notifications? This action cannot be undone.' });
|
||||
if (ok) document.getElementById('clearAllForm').submit();
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
122
app/Views/partials/confirm-modal.twig
Normal file
122
app/Views/partials/confirm-modal.twig
Normal file
@@ -0,0 +1,122 @@
|
||||
{# Global confirmation modal — replaces native confirm() dialogs.
|
||||
Included once in layout/base.twig. Provides:
|
||||
|
||||
confirmAction({ message, title, confirmText, cancelText, confirmClass, icon })
|
||||
Returns a Promise<boolean>.
|
||||
|
||||
confirmSubmit(event, message, opts)
|
||||
For onsubmit="return confirmSubmit(event, 'Delete?')"
|
||||
|
||||
confirmClick(event, message, opts)
|
||||
For onclick="return confirmClick(event, 'Are you sure?')"
|
||||
#}
|
||||
|
||||
<div id="confirmModal" class="hidden fixed inset-0 bg-black bg-opacity-50 z-[60] flex items-center justify-center p-4">
|
||||
<div class="bg-white dark:bg-slate-800 rounded-lg shadow-xl w-full max-w-sm">
|
||||
<div class="px-6 py-4 border-b border-gray-200 dark:border-slate-700 flex items-center justify-between">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white" id="confirmModalTitle">
|
||||
<i id="confirmModalIcon" class="fas fa-exclamation-triangle text-red-500 mr-2"></i>
|
||||
<span id="confirmModalTitleText">Confirm</span>
|
||||
</h3>
|
||||
<button type="button" id="confirmModalClose"
|
||||
class="text-gray-400 dark:text-slate-500 hover:text-gray-600 dark:hover:text-slate-300">
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="p-6">
|
||||
<p class="text-sm text-gray-600 dark:text-slate-400" id="confirmModalMessage"></p>
|
||||
</div>
|
||||
<div class="px-6 py-4 border-t border-gray-200 dark:border-slate-700 bg-gray-50 dark:bg-slate-900 flex justify-end gap-2 rounded-b-lg">
|
||||
<button type="button" id="confirmModalCancel"
|
||||
class="px-4 py-2 border border-gray-300 dark:border-slate-600 text-gray-700 dark:text-slate-300 rounded-lg text-sm font-medium hover:bg-gray-50 dark:hover:bg-slate-700 transition-colors">
|
||||
Cancel
|
||||
</button>
|
||||
<button type="button" id="confirmModalConfirm"
|
||||
class="px-4 py-2 text-white rounded-lg text-sm font-medium transition-colors">
|
||||
Confirm
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function() {
|
||||
var modal = document.getElementById('confirmModal');
|
||||
var titleText = document.getElementById('confirmModalTitleText');
|
||||
var icon = document.getElementById('confirmModalIcon');
|
||||
var message = document.getElementById('confirmModalMessage');
|
||||
var confirmBtn = document.getElementById('confirmModalConfirm');
|
||||
var cancelBtn = document.getElementById('confirmModalCancel');
|
||||
var closeBtn = document.getElementById('confirmModalClose');
|
||||
|
||||
var _resolve = null;
|
||||
|
||||
function close(result) {
|
||||
modal.classList.add('hidden');
|
||||
if (_resolve) { _resolve(result); _resolve = null; }
|
||||
}
|
||||
|
||||
confirmBtn.addEventListener('click', function() { close(true); });
|
||||
cancelBtn.addEventListener('click', function() { close(false); });
|
||||
closeBtn.addEventListener('click', function() { close(false); });
|
||||
|
||||
modal.addEventListener('click', function(e) {
|
||||
if (e.target === modal) close(false);
|
||||
});
|
||||
|
||||
document.addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Escape' && !modal.classList.contains('hidden')) {
|
||||
close(false);
|
||||
}
|
||||
});
|
||||
|
||||
window.confirmAction = function(opts) {
|
||||
opts = opts || {};
|
||||
|
||||
titleText.textContent = opts.title || 'Confirm';
|
||||
message.innerHTML = opts.message || 'Are you sure?';
|
||||
|
||||
var iconClass = opts.icon || 'fa-exclamation-triangle text-red-500';
|
||||
icon.className = 'fas ' + iconClass + ' mr-2';
|
||||
|
||||
confirmBtn.textContent = opts.confirmText || 'Confirm';
|
||||
cancelBtn.textContent = opts.cancelText || 'Cancel';
|
||||
|
||||
var btnClass = opts.confirmClass || 'bg-red-600 hover:bg-red-700';
|
||||
confirmBtn.className = 'px-4 py-2 text-white rounded-lg text-sm font-medium transition-colors ' + btnClass;
|
||||
|
||||
modal.classList.remove('hidden');
|
||||
confirmBtn.focus();
|
||||
|
||||
return new Promise(function(resolve) { _resolve = resolve; });
|
||||
};
|
||||
|
||||
window.confirmSubmit = function(e, msg, opts) {
|
||||
e.preventDefault();
|
||||
var form = e.target.closest('form') || e.target;
|
||||
opts = opts || {};
|
||||
opts.message = opts.message || msg;
|
||||
confirmAction(opts).then(function(ok) {
|
||||
if (ok) form.submit();
|
||||
});
|
||||
return false;
|
||||
};
|
||||
|
||||
window.confirmClick = function(e, msg, opts) {
|
||||
e.preventDefault();
|
||||
var el = e.currentTarget || e.target.closest('a') || e.target;
|
||||
opts = opts || {};
|
||||
opts.message = opts.message || msg;
|
||||
confirmAction(opts).then(function(ok) {
|
||||
if (ok) {
|
||||
if (el.tagName === 'A' && el.href) {
|
||||
window.location.href = el.href;
|
||||
} else if (el.form) {
|
||||
el.form.submit();
|
||||
}
|
||||
}
|
||||
});
|
||||
return false;
|
||||
};
|
||||
})();
|
||||
</script>
|
||||
175
app/Views/partials/import-modal.twig
Normal file
175
app/Views/partials/import-modal.twig
Normal file
@@ -0,0 +1,175 @@
|
||||
{#
|
||||
# Shared import modal with drag & drop file upload.
|
||||
#
|
||||
# Parameters:
|
||||
# prefix - Unique prefix for element IDs (e.g. 'tag', 'group', 'tld', 'dnsZone')
|
||||
# title - Modal title (e.g. 'Import Tags')
|
||||
# action - Form POST action URL
|
||||
# accept - File input accept attribute (default: '.csv,.json')
|
||||
# file_hint - Accepted file types hint (default: 'CSV, JSON')
|
||||
# format_html - Raw HTML for the "Expected Format" info block
|
||||
# submit_label - Submit button text (default: title)
|
||||
# input_name - File input name attribute (default: 'import_file')
|
||||
# extra_fields - Optional raw HTML for extra form fields (textarea, etc.)
|
||||
#}
|
||||
|
||||
{% set _accept = accept|default('.csv,.json') %}
|
||||
{% set _file_hint = file_hint|default('CSV, JSON') %}
|
||||
{% set _submit = submit_label|default(title) %}
|
||||
{% set _input_name = input_name|default('import_file') %}
|
||||
|
||||
<div id="{{ prefix }}ImportModal" class="hidden fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center p-4">
|
||||
<div class="bg-white dark:bg-slate-800 rounded-lg shadow-xl w-full max-w-md">
|
||||
<div class="px-6 py-4 border-b border-gray-200 dark:border-slate-700 flex items-center justify-between">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
<i class="fas fa-upload text-primary mr-2"></i>{{ title }}
|
||||
</h3>
|
||||
<button onclick="document.getElementById('{{ prefix }}ImportModal').classList.add('hidden')"
|
||||
class="text-gray-400 dark:text-slate-500 hover:text-gray-600 dark:hover:text-slate-300">
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
<form method="POST" action="{{ action }}" enctype="multipart/form-data" id="{{ prefix }}ImportForm">
|
||||
{{ csrf_field() }}
|
||||
<div class="p-6 space-y-4">
|
||||
{% if extra_fields is defined and extra_fields %}
|
||||
{{ extra_fields|raw }}
|
||||
{% endif %}
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-slate-300 mb-1.5">Select File</label>
|
||||
<div id="{{ prefix }}Dropzone" class="relative border-2 border-dashed border-gray-300 dark:border-slate-600 rounded-lg p-6 text-center cursor-pointer transition-all hover:border-primary hover:bg-gray-50 dark:hover:bg-slate-700">
|
||||
<input type="file" name="{{ _input_name }}" accept="{{ _accept }}" required id="{{ prefix }}FileInput"
|
||||
class="absolute inset-0 w-full h-full opacity-0 cursor-pointer z-10">
|
||||
<div id="{{ prefix }}DropzoneContent">
|
||||
<i class="fas fa-cloud-upload-alt text-3xl text-gray-400 dark:text-slate-500 mb-2"></i>
|
||||
<p class="text-sm text-gray-600 dark:text-slate-400 font-medium">Drag & drop your file here</p>
|
||||
<p class="text-xs text-gray-400 dark:text-slate-500 my-1">or</p>
|
||||
<span class="inline-flex items-center px-3 py-1.5 bg-primary text-white text-xs rounded-lg font-medium">
|
||||
<i class="fas fa-folder-open mr-1.5"></i>Browse Files
|
||||
</span>
|
||||
<p class="mt-2.5 text-xs text-gray-400 dark:text-slate-500">{{ _file_hint }} · Max {{ max_upload_size() }}</p>
|
||||
</div>
|
||||
<div id="{{ prefix }}DropzoneFile" class="hidden">
|
||||
<i class="fas fa-file-alt text-2xl text-primary mb-1.5"></i>
|
||||
<p class="text-sm font-medium text-gray-700 dark:text-slate-300" id="{{ prefix }}FileName"></p>
|
||||
<p class="text-xs text-gray-400 dark:text-slate-500" id="{{ prefix }}FileSize"></p>
|
||||
<button type="button" id="{{ prefix }}FileRemove" class="mt-1.5 text-xs text-red-500 hover:text-red-700 font-medium">
|
||||
<i class="fas fa-trash-alt mr-1"></i>Remove
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if format_html is defined and format_html %}
|
||||
<div class="bg-blue-50 dark:bg-blue-500/10 border border-blue-200 dark:border-blue-500/30 rounded-lg p-3">
|
||||
<p class="text-xs text-gray-700 dark:text-slate-300 font-medium mb-1">
|
||||
<i class="fas fa-info-circle text-blue-500 mr-1"></i> Expected Format
|
||||
</p>
|
||||
{{ format_html|raw }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="px-6 py-4 border-t border-gray-200 dark:border-slate-700 bg-gray-50 dark:bg-slate-900 flex justify-end gap-2 rounded-b-lg">
|
||||
<button type="button" onclick="document.getElementById('{{ prefix }}ImportModal').classList.add('hidden')"
|
||||
class="px-4 py-2 border border-gray-300 dark:border-slate-600 text-gray-700 dark:text-slate-300 rounded-lg text-sm font-medium hover:bg-gray-50 dark:hover:bg-slate-700 transition-colors">
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit" id="{{ prefix }}ImportBtn"
|
||||
class="px-4 py-2 bg-primary text-white rounded-lg text-sm font-medium hover:bg-primary-dark transition-colors">
|
||||
<i class="fas fa-upload mr-1.5"></i>{{ _submit }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function() {
|
||||
var pfx = '{{ prefix }}';
|
||||
var dropzone = document.getElementById(pfx + 'Dropzone');
|
||||
if (!dropzone) return;
|
||||
var fileInput = document.getElementById(pfx + 'FileInput');
|
||||
var content = document.getElementById(pfx + 'DropzoneContent');
|
||||
var fileInfo = document.getElementById(pfx + 'DropzoneFile');
|
||||
var fileName = document.getElementById(pfx + 'FileName');
|
||||
var fileSize = document.getElementById(pfx + 'FileSize');
|
||||
var removeBtn = document.getElementById(pfx + 'FileRemove');
|
||||
var form = document.getElementById(pfx + 'ImportForm');
|
||||
var submitBtn = document.getElementById(pfx + 'ImportBtn');
|
||||
|
||||
function formatSize(bytes) {
|
||||
if (bytes >= 1048576) return (bytes / 1048576).toFixed(1) + ' MB';
|
||||
if (bytes >= 1024) return (bytes / 1024).toFixed(1) + ' KB';
|
||||
return bytes + ' B';
|
||||
}
|
||||
|
||||
function showFile(file) {
|
||||
fileName.textContent = file.name;
|
||||
fileSize.textContent = formatSize(file.size);
|
||||
content.classList.add('hidden');
|
||||
fileInfo.classList.remove('hidden');
|
||||
dropzone.classList.remove('border-gray-300');
|
||||
dropzone.classList.add('border-primary', 'bg-primary/5');
|
||||
}
|
||||
|
||||
function resetDropzone() {
|
||||
fileInput.value = '';
|
||||
content.classList.remove('hidden');
|
||||
fileInfo.classList.add('hidden');
|
||||
dropzone.classList.add('border-gray-300');
|
||||
dropzone.classList.remove('border-primary', 'bg-primary/5');
|
||||
}
|
||||
|
||||
fileInput.addEventListener('change', function() {
|
||||
if (this.files.length) showFile(this.files[0]);
|
||||
});
|
||||
|
||||
removeBtn.addEventListener('click', function(e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
resetDropzone();
|
||||
});
|
||||
|
||||
['dragenter', 'dragover'].forEach(function(evt) {
|
||||
dropzone.addEventListener(evt, function(e) {
|
||||
e.preventDefault();
|
||||
dropzone.classList.add('border-primary', 'bg-primary/5');
|
||||
dropzone.classList.remove('border-gray-300');
|
||||
});
|
||||
});
|
||||
|
||||
['dragleave', 'drop'].forEach(function(evt) {
|
||||
dropzone.addEventListener(evt, function(e) {
|
||||
e.preventDefault();
|
||||
if (!fileInput.files.length) {
|
||||
dropzone.classList.remove('border-primary', 'bg-primary/5');
|
||||
dropzone.classList.add('border-gray-300');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
dropzone.addEventListener('drop', function(e) {
|
||||
e.preventDefault();
|
||||
var files = e.dataTransfer.files;
|
||||
if (files.length) {
|
||||
fileInput.files = files;
|
||||
showFile(files[0]);
|
||||
}
|
||||
});
|
||||
|
||||
form.addEventListener('submit', function() {
|
||||
submitBtn.disabled = true;
|
||||
submitBtn.innerHTML = '<i class="fas fa-spinner fa-spin mr-1.5"></i>Importing...';
|
||||
});
|
||||
|
||||
document.addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Escape') {
|
||||
var modal = document.getElementById(pfx + 'ImportModal');
|
||||
if (modal && !modal.classList.contains('hidden')) {
|
||||
modal.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
182
app/Views/partials/transfer-modal.twig
Normal file
182
app/Views/partials/transfer-modal.twig
Normal file
@@ -0,0 +1,182 @@
|
||||
{# Shared transfer modal component.
|
||||
Include once per page, then call:
|
||||
|
||||
openTransferModal({
|
||||
title: 'Transfer Domain',
|
||||
description: 'Transfer <strong>example.com</strong> to another user.',
|
||||
action: '/domains/transfer',
|
||||
fields: { domain_id: 123 } // or for arrays: { 'tag_ids[]': [1,2,3] }
|
||||
submitText: 'Transfer', // optional, defaults to 'Transfer'
|
||||
users: [...], // user objects with id, username, full_name/email
|
||||
csrfToken: '...'
|
||||
});
|
||||
#}
|
||||
<script>
|
||||
(function() {
|
||||
const _esc = (s) => String(s).replace(/&/g,'&').replace(/"/g,'"').replace(/'/g,''').replace(/</g,'<').replace(/>/g,'>');
|
||||
|
||||
function _buildSearchableSelect(container, hiddenInput, users) {
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.className = 'relative';
|
||||
wrapper.innerHTML = `
|
||||
<div class="transfer-picker-selected hidden flex items-center justify-between px-3 py-2 border border-gray-300 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-700 text-sm text-gray-900 dark:text-white cursor-pointer hover:border-primary transition-colors">
|
||||
<span class="transfer-picker-selected-text truncate"></span>
|
||||
<button type="button" class="transfer-picker-clear ml-2 text-gray-400 hover:text-red-500 dark:text-slate-500 dark:hover:text-red-400 flex-shrink-0" title="Clear selection">
|
||||
<i class="fas fa-times text-xs"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="transfer-picker-search-wrap">
|
||||
<div class="relative">
|
||||
<input type="text" class="transfer-picker-search w-full pl-9 pr-3 py-2 border border-gray-300 dark:border-slate-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary focus:border-primary text-sm bg-white dark:bg-slate-700 text-gray-900 dark:text-white" placeholder="Search users..." autocomplete="off">
|
||||
<i class="fas fa-search absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 dark:text-slate-500 text-xs"></i>
|
||||
</div>
|
||||
<div class="transfer-picker-list mt-1 max-h-48 overflow-y-auto border border-gray-200 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-800 shadow-lg"></div>
|
||||
</div>
|
||||
`;
|
||||
container.appendChild(wrapper);
|
||||
|
||||
const searchInput = wrapper.querySelector('.transfer-picker-search');
|
||||
const searchWrap = wrapper.querySelector('.transfer-picker-search-wrap');
|
||||
const listEl = wrapper.querySelector('.transfer-picker-list');
|
||||
const selectedEl = wrapper.querySelector('.transfer-picker-selected');
|
||||
const selectedText = wrapper.querySelector('.transfer-picker-selected-text');
|
||||
const clearBtn = wrapper.querySelector('.transfer-picker-clear');
|
||||
|
||||
function renderList(filter) {
|
||||
const query = (filter || '').toLowerCase();
|
||||
const filtered = users.filter(u => {
|
||||
const uname = (u.username || '').toLowerCase();
|
||||
const fname = (u.full_name || u.email || '').toLowerCase();
|
||||
return uname.includes(query) || fname.includes(query);
|
||||
});
|
||||
|
||||
if (filtered.length === 0) {
|
||||
listEl.innerHTML = '<div class="px-3 py-2.5 text-sm text-gray-400 dark:text-slate-500 italic">No users found</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
listEl.innerHTML = filtered.map(u =>
|
||||
`<div class="transfer-picker-item px-3 py-2.5 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-slate-700 text-gray-900 dark:text-white flex items-center justify-between transition-colors" data-user-id="${u.id}">
|
||||
<div>
|
||||
<span class="font-medium">${_esc(u.username)}</span>
|
||||
<span class="text-gray-500 dark:text-slate-400 ml-1">(${_esc(u.full_name || u.email || 'No name')})</span>
|
||||
</div>
|
||||
<i class="fas fa-chevron-right text-xs text-gray-300 dark:text-slate-600"></i>
|
||||
</div>`
|
||||
).join('');
|
||||
|
||||
listEl.querySelectorAll('.transfer-picker-item').forEach(item => {
|
||||
item.addEventListener('click', () => {
|
||||
const userId = item.dataset.userId;
|
||||
const user = users.find(u => String(u.id) === userId);
|
||||
if (!user) return;
|
||||
hiddenInput.value = userId;
|
||||
selectedText.innerHTML = `<i class="fas fa-user mr-2 text-primary"></i><span class="font-medium">${_esc(user.username)}</span> <span class="text-gray-500 dark:text-slate-400 ml-1">(${_esc(user.full_name || user.email || 'No name')})</span>`;
|
||||
selectedEl.classList.remove('hidden');
|
||||
searchWrap.classList.add('hidden');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
renderList('');
|
||||
searchInput.addEventListener('input', () => renderList(searchInput.value));
|
||||
|
||||
clearBtn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
hiddenInput.value = '';
|
||||
selectedEl.classList.add('hidden');
|
||||
searchWrap.classList.remove('hidden');
|
||||
searchInput.value = '';
|
||||
renderList('');
|
||||
searchInput.focus();
|
||||
});
|
||||
|
||||
selectedEl.addEventListener('click', (e) => {
|
||||
if (e.target.closest('.transfer-picker-clear')) return;
|
||||
selectedEl.classList.add('hidden');
|
||||
searchWrap.classList.remove('hidden');
|
||||
searchInput.focus();
|
||||
});
|
||||
|
||||
setTimeout(() => searchInput.focus(), 50);
|
||||
}
|
||||
|
||||
window.openTransferModal = function(opts) {
|
||||
const users = opts.users || [];
|
||||
if (users.length === 0) {
|
||||
alert('No users available for transfer');
|
||||
return;
|
||||
}
|
||||
|
||||
let fieldsHtml = '';
|
||||
if (opts.fields) {
|
||||
Object.entries(opts.fields).forEach(([name, value]) => {
|
||||
if (Array.isArray(value)) {
|
||||
value.forEach(v => {
|
||||
fieldsHtml += `<input type="hidden" name="${_esc(name)}" value="${_esc(v)}">`;
|
||||
});
|
||||
} else {
|
||||
fieldsHtml += `<input type="hidden" name="${_esc(name)}" value="${_esc(value)}">`;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const modal = document.createElement('div');
|
||||
modal.className = 'fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center p-4';
|
||||
modal.innerHTML = `
|
||||
<div class="bg-white dark:bg-slate-800 rounded-lg shadow-xl w-full max-w-md">
|
||||
<form method="POST" action="${_esc(opts.action)}" onsubmit="return !!this.querySelector('input[name=target_user_id]').value">
|
||||
<input type="hidden" name="csrf_token" value="${_esc(opts.csrfToken)}">
|
||||
${fieldsHtml}
|
||||
|
||||
<div class="px-6 py-4 border-b border-gray-200 dark:border-slate-700 flex items-center justify-between">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
<i class="fas fa-exchange-alt text-primary mr-2"></i>${opts.title || 'Transfer'}
|
||||
</h3>
|
||||
<button type="button" class="transfer-modal-cancel text-gray-400 dark:text-slate-500 hover:text-gray-600 dark:hover:text-slate-300">
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="p-6 space-y-4">
|
||||
${opts.description ? `<p class="text-sm text-gray-500 dark:text-slate-400">${opts.description}</p>` : ''}
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-slate-300 mb-1.5">Transfer to User</label>
|
||||
<input type="hidden" name="target_user_id" value="">
|
||||
<div class="user-picker-mount"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="px-6 py-4 border-t border-gray-200 dark:border-slate-700 bg-gray-50 dark:bg-slate-900 flex justify-end gap-2 rounded-b-lg">
|
||||
<button type="button" class="transfer-modal-cancel px-4 py-2 border border-gray-300 dark:border-slate-600 text-gray-700 dark:text-slate-300 rounded-lg text-sm font-medium hover:bg-gray-50 dark:hover:bg-slate-700 transition-colors">
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit" class="px-4 py-2 bg-primary text-white rounded-lg text-sm font-medium hover:bg-primary-dark transition-colors">
|
||||
<i class="fas fa-exchange-alt mr-1.5"></i>${_esc(opts.submitText || 'Transfer')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
`;
|
||||
|
||||
document.body.appendChild(modal);
|
||||
|
||||
const mount = modal.querySelector('.user-picker-mount');
|
||||
const hiddenInput = modal.querySelector('input[name="target_user_id"]');
|
||||
_buildSearchableSelect(mount, hiddenInput, users);
|
||||
|
||||
modal.querySelectorAll('.transfer-modal-cancel').forEach(btn => {
|
||||
btn.addEventListener('click', () => modal.remove());
|
||||
});
|
||||
modal.addEventListener('click', (e) => {
|
||||
if (e.target === modal) modal.remove();
|
||||
});
|
||||
document.addEventListener('keydown', function handler(e) {
|
||||
if (e.key === 'Escape' && document.body.contains(modal)) {
|
||||
modal.remove();
|
||||
document.removeEventListener('keydown', handler);
|
||||
}
|
||||
});
|
||||
};
|
||||
})();
|
||||
</script>
|
||||
@@ -158,7 +158,7 @@
|
||||
{{ csrf_field() }}
|
||||
<button type="submit"
|
||||
class="inline-flex items-center px-3 py-2 border border-red-300 dark:border-red-500/30 rounded-lg text-sm font-medium text-red-700 dark:text-red-400 bg-white dark:bg-slate-700 hover:bg-red-50 dark:hover:bg-red-500/10"
|
||||
onclick="return confirm('Are you sure you want to remove your avatar?')">
|
||||
onclick="return confirmClick(event, 'Are you sure you want to remove your avatar?', { title: 'Remove Avatar', icon: 'fa-user-circle text-red-500' })">
|
||||
<i class="fas fa-trash mr-2"></i>
|
||||
Remove
|
||||
</button>
|
||||
@@ -350,7 +350,7 @@
|
||||
|
||||
<div class="flex flex-col sm:flex-row gap-3">
|
||||
{% if twoFactorStatus.backup_codes_count < 3 %}
|
||||
<form method="POST" action="/2fa/regenerate-backup-codes" onsubmit="return confirm('Generate new backup codes? Your current codes will stop working.')">
|
||||
<form method="POST" action="/2fa/regenerate-backup-codes" onsubmit="return confirmSubmit(event, 'Generate new backup codes? Your current codes will stop working.', { title: 'Regenerate Codes', icon: 'fa-key text-blue-500', confirmText: 'Generate', confirmClass: 'bg-blue-600 hover:bg-blue-700' })">
|
||||
{{ csrf_field() }}
|
||||
<button type="submit" class="inline-flex items-center px-4 py-2 bg-blue-600 text-white text-sm rounded-lg hover:bg-blue-700 transition-colors font-medium">
|
||||
<i class="fas fa-refresh mr-2"></i>
|
||||
@@ -493,7 +493,7 @@
|
||||
<p class="text-sm text-gray-600 dark:text-slate-400 mt-1">Manage devices and sessions where you're logged in ({{ sessions|default([])|length }} active)</p>
|
||||
</div>
|
||||
{% if sessions|default([])|length > 1 %}
|
||||
<form method="POST" action="/profile/logout-other-sessions" onsubmit="return confirm('Logout all other sessions?')" class="inline">
|
||||
<form method="POST" action="/profile/logout-other-sessions" onsubmit="return confirmSubmit(event, 'Logout all other sessions?', { title: 'Logout Sessions', icon: 'fa-sign-out-alt text-red-500' })" class="inline">
|
||||
{{ csrf_field() }}
|
||||
<button type="submit" class="inline-flex items-center px-3 py-2 bg-red-600 text-white text-xs rounded-lg hover:bg-red-700 transition-colors font-medium">
|
||||
<i class="fas fa-sign-out-alt mr-1.5"></i>
|
||||
@@ -578,7 +578,7 @@
|
||||
|
||||
<!-- Delete Button (only for non-current sessions) -->
|
||||
{% if not isCurrent %}
|
||||
<form method="POST" action="/profile/logout-session/{{ session.id }}" onsubmit="return confirm('Terminate this session?\n\nThat device will be logged out immediately.')" class="ml-3">
|
||||
<form method="POST" action="/profile/logout-session/{{ session.id }}" onsubmit="return confirmSubmit(event, 'Terminate this session? That device will be logged out immediately.', { title: 'Terminate Session', icon: 'fa-sign-out-alt text-red-500' })" class="ml-3">
|
||||
{{ csrf_field() }}
|
||||
<button type="submit" class="flex items-center justify-center w-8 h-8 bg-red-100 dark:bg-red-500/20 text-red-600 dark:text-red-400 rounded-lg hover:bg-red-600 hover:text-white dark:hover:bg-red-600 transition-colors" title="Terminate session">
|
||||
<i class="fas fa-times text-sm"></i>
|
||||
@@ -712,12 +712,11 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
}
|
||||
});
|
||||
|
||||
function confirmDelete() {
|
||||
if (confirm('Are you absolutely sure you want to delete your account?\n\nThis action is PERMANENT and cannot be undone!')) {
|
||||
if (confirm('FINAL WARNING: This will permanently delete all your data.\n\nClick OK to proceed.')) {
|
||||
document.getElementById('deleteAccountForm').submit();
|
||||
}
|
||||
}
|
||||
async function confirmDelete() {
|
||||
var ok = await confirmAction({ message: 'Are you absolutely sure you want to delete your account? This action is PERMANENT and cannot be undone!', title: 'Delete Account', icon: 'fa-skull-crossbones text-red-600' });
|
||||
if (!ok) return;
|
||||
var ok2 = await confirmAction({ message: 'FINAL WARNING: This will permanently delete all your data. Click Confirm to proceed.', title: 'Final Confirmation', icon: 'fa-exclamation-circle text-red-600' });
|
||||
if (ok2) document.getElementById('deleteAccountForm').submit();
|
||||
}
|
||||
|
||||
function showDisable2FAModal() {
|
||||
|
||||
@@ -93,25 +93,27 @@
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="app_timezone" class="block text-sm font-medium text-gray-700 dark:text-slate-300 mb-2">
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-slate-300 mb-2">
|
||||
Timezone
|
||||
</label>
|
||||
<select id="app_timezone" name="app_timezone" required
|
||||
class="w-full px-3 py-2 border border-gray-300 dark:border-slate-600 rounded-lg focus:ring-2 focus:ring-primary focus:border-primary bg-white dark:bg-slate-700 text-gray-900 dark:text-white">
|
||||
{% for tz, label in popularTimezones %}
|
||||
<option value="{{ tz }}" {{ appSettings.app_timezone == tz ? 'selected' : '' }}>
|
||||
{{ label }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
<option disabled>──────────</option>
|
||||
{% for tz in allTimezones %}
|
||||
{% if tz not in popularTimezones|keys %}
|
||||
<option value="{{ tz }}" {{ appSettings.app_timezone == tz ? 'selected' : '' }}>
|
||||
{{ tz }}
|
||||
</option>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</select>
|
||||
<input type="hidden" id="app_timezone" name="app_timezone" value="{{ appSettings.app_timezone }}" required>
|
||||
<div id="tz-picker" class="relative">
|
||||
<div id="tz-selected" class="flex items-center justify-between px-3 py-2 border border-gray-300 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-700 text-sm text-gray-900 dark:text-white cursor-pointer hover:border-primary">
|
||||
<span id="tz-selected-text">
|
||||
<i class="fas fa-globe mr-2 text-primary"></i>{{ popularTimezones[appSettings.app_timezone] is defined ? popularTimezones[appSettings.app_timezone] : appSettings.app_timezone|default('UTC') }}
|
||||
</span>
|
||||
<i class="fas fa-chevron-down text-xs text-gray-400 dark:text-slate-500"></i>
|
||||
</div>
|
||||
<div id="tz-dropdown" class="hidden absolute z-20 left-0 right-0 mt-1 border border-gray-200 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-700 shadow-lg">
|
||||
<div class="p-2 border-b border-gray-200 dark:border-slate-600">
|
||||
<div class="relative">
|
||||
<input type="text" id="tz-search" class="w-full pl-9 pr-3 py-2 border border-gray-300 dark:border-slate-600 rounded-lg focus:ring-2 focus:ring-primary focus:border-primary text-sm bg-white dark:bg-slate-700 text-gray-900 dark:text-white" placeholder="Search timezones..." autocomplete="off">
|
||||
<i class="fas fa-search absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 dark:text-slate-500 text-xs"></i>
|
||||
</div>
|
||||
</div>
|
||||
<div id="tz-list" class="max-h-64 overflow-y-auto"></div>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-xs text-gray-500 dark:text-slate-400 mt-1">Application timezone for dates and times</p>
|
||||
</div>
|
||||
|
||||
@@ -970,7 +972,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form method="POST" action="/settings/clear-logs" onsubmit="return confirm('Are you sure you want to clear logs older than 30 days? This action cannot be undone.')">
|
||||
<form method="POST" action="/settings/clear-logs" onsubmit="return confirmSubmit(event, 'Are you sure you want to clear logs older than 30 days? This action cannot be undone.')">
|
||||
{{ csrf_field() }}
|
||||
<button type="submit" class="inline-flex items-center px-4 py-2.5 bg-red-600 text-white text-sm rounded-lg hover:bg-red-700 transition-colors font-medium">
|
||||
<i class="fas fa-trash-alt mr-2"></i>
|
||||
@@ -1168,7 +1170,7 @@
|
||||
</div>
|
||||
|
||||
<form method="POST" action="/settings/updates/rollback" class="mt-4"
|
||||
onsubmit="return confirm('Are you sure you want to rollback? This will restore files to the previous version.')">
|
||||
onsubmit="return confirmSubmit(event, 'Are you sure you want to rollback? This will restore files to the previous version.')">
|
||||
{{ csrf_field() }}
|
||||
<button type="submit" class="inline-flex items-center px-4 py-2.5 bg-red-600 text-white text-sm rounded-lg hover:bg-red-700 transition-colors font-medium">
|
||||
<i class="fas fa-undo mr-2"></i>
|
||||
@@ -1449,13 +1451,13 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
if (footerEl) {
|
||||
footerEl.className = 'px-4 py-3 flex-shrink-0 border-t rounded-b-xl ' + (isRelease ? 'bg-blue-100 dark:bg-blue-500/20 border-blue-200 dark:border-blue-500/20' : 'bg-amber-100 dark:bg-amber-500/20 border-amber-200 dark:border-amber-500/20');
|
||||
if (isRelease) {
|
||||
footerEl.innerHTML = '<form method="POST" action="/settings/updates/apply" onsubmit="return confirm(\'Apply update to v' + escapeHtml(data.latest_version || '') + '? A backup will be created before updating.\')">' +
|
||||
footerEl.innerHTML = '<form method="POST" action="/settings/updates/apply" onsubmit="return confirmSubmit(event, \'Apply update to v' + escapeHtml(data.latest_version || '') + '? A backup will be created before updating.\', { title: \'Update\', icon: \'fa-download text-blue-500\', confirmText: \'Update\', confirmClass: \'bg-blue-600 hover:bg-blue-700\' })">' +
|
||||
(csrf ? '<input type="hidden" name="csrf_token" value="' + escapeHtml(csrf) + '">' : '') +
|
||||
'<input type="hidden" name="update_type" value="release">' +
|
||||
'<button type="submit" class="inline-flex items-center px-4 py-2 bg-blue-600 text-white text-sm rounded-lg hover:bg-blue-700 transition-colors font-medium"><i class="fas fa-download mr-2"></i> Update to v' + escapeHtml(data.latest_version || '') + '</button>' +
|
||||
'</form>';
|
||||
} else {
|
||||
footerEl.innerHTML = '<form method="POST" action="/settings/updates/apply" onsubmit="return confirm(\'Apply hotfix? This will update your files to the latest main branch. A backup will be created first.\')">' +
|
||||
footerEl.innerHTML = '<form method="POST" action="/settings/updates/apply" onsubmit="return confirmSubmit(event, \'Apply hotfix? This will update your files to the latest main branch. A backup will be created first.\', { title: \'Apply Hotfix\', icon: \'fa-download text-amber-500\', confirmText: \'Apply Hotfix\', confirmClass: \'bg-amber-600 hover:bg-amber-700\' })">' +
|
||||
(csrf ? '<input type="hidden" name="csrf_token" value="' + escapeHtml(csrf) + '">' : '') +
|
||||
'<input type="hidden" name="update_type" value="hotfix">' +
|
||||
'<button type="submit" class="inline-flex items-center px-4 py-2 bg-amber-600 text-white text-sm rounded-lg hover:bg-amber-700 transition-colors font-medium"><i class="fas fa-download mr-2"></i> Apply Hotfix</button>' +
|
||||
@@ -1591,5 +1593,99 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
updateCaptchaUI();
|
||||
}
|
||||
});
|
||||
|
||||
(function() {
|
||||
const popularTimezones = {{ popularTimezones|json_encode|raw }};
|
||||
const allTimezones = {{ allTimezones|json_encode|raw }};
|
||||
|
||||
const hiddenInput = document.getElementById('app_timezone');
|
||||
const selectedEl = document.getElementById('tz-selected');
|
||||
const selectedText = document.getElementById('tz-selected-text');
|
||||
const dropdown = document.getElementById('tz-dropdown');
|
||||
const searchInput = document.getElementById('tz-search');
|
||||
const listEl = document.getElementById('tz-list');
|
||||
|
||||
const popularKeys = Object.keys(popularTimezones);
|
||||
const otherTimezones = allTimezones.filter(tz => !popularKeys.includes(tz));
|
||||
|
||||
const esc = (s) => String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');
|
||||
|
||||
function renderList(filter) {
|
||||
const query = (filter || '').toLowerCase();
|
||||
|
||||
const matchPopular = popularKeys.filter(tz => {
|
||||
const label = (popularTimezones[tz] || '').toLowerCase();
|
||||
return tz.toLowerCase().includes(query) || label.includes(query);
|
||||
});
|
||||
|
||||
const matchOther = otherTimezones.filter(tz => tz.toLowerCase().includes(query));
|
||||
|
||||
if (matchPopular.length === 0 && matchOther.length === 0) {
|
||||
listEl.innerHTML = '<div class="px-3 py-2 text-sm text-gray-400 dark:text-slate-500 italic">No timezones found</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
let html = '';
|
||||
|
||||
if (matchPopular.length > 0) {
|
||||
html += '<div class="px-3 py-1.5 text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase tracking-wider bg-gray-50 dark:bg-slate-800">Popular</div>';
|
||||
html += matchPopular.map(tz =>
|
||||
`<div class="tz-item px-3 py-2 text-sm cursor-pointer hover:bg-primary/10 dark:hover:bg-primary/20 text-gray-900 dark:text-white flex items-center justify-between${hiddenInput.value === tz ? ' bg-primary/5 dark:bg-primary/10' : ''}" data-tz="${esc(tz)}">
|
||||
<span>${esc(popularTimezones[tz])}</span>
|
||||
${hiddenInput.value === tz ? '<i class="fas fa-check text-primary text-xs"></i>' : ''}
|
||||
</div>`
|
||||
).join('');
|
||||
}
|
||||
|
||||
if (matchOther.length > 0) {
|
||||
html += '<div class="px-3 py-1.5 text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase tracking-wider bg-gray-50 dark:bg-slate-800 border-t border-gray-200 dark:border-slate-600">All Timezones</div>';
|
||||
const capped = matchOther.slice(0, 50);
|
||||
html += capped.map(tz =>
|
||||
`<div class="tz-item px-3 py-2 text-sm cursor-pointer hover:bg-primary/10 dark:hover:bg-primary/20 text-gray-900 dark:text-white flex items-center justify-between${hiddenInput.value === tz ? ' bg-primary/5 dark:bg-primary/10' : ''}" data-tz="${esc(tz)}">
|
||||
<span>${esc(tz)}</span>
|
||||
${hiddenInput.value === tz ? '<i class="fas fa-check text-primary text-xs"></i>' : ''}
|
||||
</div>`
|
||||
).join('');
|
||||
if (matchOther.length > 50) {
|
||||
html += '<div class="px-3 py-2 text-xs text-gray-400 dark:text-slate-500 italic">Type to narrow down ' + (matchOther.length - 50) + ' more...</div>';
|
||||
}
|
||||
}
|
||||
|
||||
listEl.innerHTML = html;
|
||||
|
||||
listEl.querySelectorAll('.tz-item').forEach(item => {
|
||||
item.addEventListener('click', () => {
|
||||
const tz = item.dataset.tz;
|
||||
hiddenInput.value = tz;
|
||||
const label = popularTimezones[tz] || tz;
|
||||
selectedText.innerHTML = '<i class="fas fa-globe mr-2 text-primary"></i>' + esc(label);
|
||||
dropdown.classList.add('hidden');
|
||||
searchInput.value = '';
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
selectedEl.addEventListener('click', () => {
|
||||
dropdown.classList.toggle('hidden');
|
||||
if (!dropdown.classList.contains('hidden')) {
|
||||
renderList('');
|
||||
setTimeout(() => searchInput.focus(), 50);
|
||||
}
|
||||
});
|
||||
|
||||
searchInput.addEventListener('input', () => renderList(searchInput.value));
|
||||
|
||||
document.addEventListener('click', (e) => {
|
||||
if (!document.getElementById('tz-picker').contains(e.target)) {
|
||||
dropdown.classList.add('hidden');
|
||||
}
|
||||
});
|
||||
|
||||
searchInput.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape') {
|
||||
dropdown.classList.add('hidden');
|
||||
}
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
</div>
|
||||
</div>
|
||||
{# Import Button #}
|
||||
<button onclick="document.getElementById('importModal').classList.remove('hidden')" class="inline-flex items-center px-4 py-2 bg-primary text-white text-sm rounded-lg hover:bg-primary-dark transition-colors font-medium">
|
||||
<button onclick="document.getElementById('tagImportModal').classList.remove('hidden')" class="inline-flex items-center px-4 py-2 bg-primary text-white text-sm rounded-lg hover:bg-primary-dark transition-colors font-medium">
|
||||
<i class="fas fa-upload mr-2"></i>
|
||||
Import
|
||||
</button>
|
||||
@@ -198,7 +198,7 @@
|
||||
<a href="/tags/{{ tag.id }}" class="text-blue-600 hover:text-blue-800" title="View">
|
||||
<i class="fas fa-eye"></i>
|
||||
</a>
|
||||
{% if auth.isAdmin %}
|
||||
{% if auth.isAdmin and tag.user_id is not null %}
|
||||
<button type="button" class="tag-transfer-btn text-green-600 hover:text-green-800" title="Transfer Tag"
|
||||
data-tag-id="{{ tag.id }}" data-tag-name="{{ tag.name }}">
|
||||
<i class="fas fa-exchange-alt"></i>
|
||||
@@ -256,7 +256,7 @@
|
||||
class="text-blue-600 hover:text-blue-800" title="View">
|
||||
<i class="fas fa-eye"></i>
|
||||
</a>
|
||||
{% if auth.isAdmin %}
|
||||
{% if auth.isAdmin and tag.user_id is not null %}
|
||||
<button type="button" class="tag-transfer-btn text-green-600 hover:text-green-800" title="Transfer Tag"
|
||||
data-tag-id="{{ tag.id }}" data-tag-name="{{ tag.name }}">
|
||||
<i class="fas fa-exchange-alt"></i>
|
||||
@@ -455,63 +455,12 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Import Modal #}
|
||||
<div id="importModal" class="hidden fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center p-4">
|
||||
<div class="bg-white dark:bg-slate-800 rounded-lg shadow-xl w-full max-w-md">
|
||||
<div class="px-6 py-4 border-b border-gray-200 dark:border-slate-700 flex items-center justify-between">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
<i class="fas fa-upload text-primary mr-2"></i>Import Tags
|
||||
</h3>
|
||||
<button onclick="document.getElementById('importModal').classList.add('hidden')" class="text-gray-400 dark:text-slate-500 hover:text-gray-600 dark:hover:text-slate-300">
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
<form method="POST" action="/tags/import" enctype="multipart/form-data" id="tagImportForm">
|
||||
{{ csrf_field() }}
|
||||
<div class="p-6 space-y-4">
|
||||
{# Drag & Drop Zone #}
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-slate-300 mb-1.5">Select File</label>
|
||||
<div id="tagDropzone" class="relative border-2 border-dashed border-gray-300 dark:border-slate-600 rounded-lg p-6 text-center cursor-pointer transition-all hover:border-primary hover:bg-gray-50 dark:hover:bg-slate-700">
|
||||
<input type="file" name="import_file" accept=".csv,.json" required id="tagFileInput"
|
||||
class="absolute inset-0 w-full h-full opacity-0 cursor-pointer z-10">
|
||||
<div id="tagDropzoneContent">
|
||||
<i class="fas fa-cloud-upload-alt text-3xl text-gray-400 dark:text-slate-500 mb-2"></i>
|
||||
<p class="text-sm text-gray-600 dark:text-slate-400 font-medium">Drag & drop your file here</p>
|
||||
<p class="text-xs text-gray-400 dark:text-slate-500 my-1">or</p>
|
||||
<span class="inline-flex items-center px-3 py-1.5 bg-primary text-white text-xs rounded-lg font-medium">
|
||||
<i class="fas fa-folder-open mr-1.5"></i>Browse Files
|
||||
</span>
|
||||
<p class="mt-2.5 text-xs text-gray-400 dark:text-slate-500">CSV, JSON · Max {{ max_upload_size() }}</p>
|
||||
</div>
|
||||
<div id="tagDropzoneFile" class="hidden">
|
||||
<i class="fas fa-file-alt text-2xl text-primary mb-1.5"></i>
|
||||
<p class="text-sm font-medium text-gray-700 dark:text-slate-300" id="tagFileName"></p>
|
||||
<p class="text-xs text-gray-400 dark:text-slate-500" id="tagFileSize"></p>
|
||||
<button type="button" id="tagFileRemove" class="mt-1.5 text-xs text-red-500 hover:text-red-700 font-medium">
|
||||
<i class="fas fa-trash-alt mr-1"></i>Remove
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-blue-50 dark:bg-blue-500/10 border border-blue-200 dark:border-blue-500/30 rounded-lg p-3">
|
||||
<p class="text-xs text-gray-700 dark:text-slate-300 font-medium mb-1"><i class="fas fa-info-circle text-blue-500 mr-1"></i> Expected Format</p>
|
||||
<p class="text-xs text-gray-600 dark:text-slate-400">CSV columns: <code class="bg-white dark:bg-slate-700 px-1 rounded">name, color, description</code></p>
|
||||
<p class="text-xs text-gray-600 dark:text-slate-400 mt-0.5">JSON: array of objects with same fields</p>
|
||||
<p class="text-xs text-gray-500 dark:text-slate-400 mt-1">Tags that already exist will be skipped. Only your private tags are imported.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="px-6 py-4 border-t border-gray-200 dark:border-slate-700 bg-gray-50 dark:bg-slate-900 flex justify-end gap-2 rounded-b-lg">
|
||||
<button type="button" onclick="document.getElementById('importModal').classList.add('hidden')" class="px-4 py-2 border border-gray-300 dark:border-slate-600 text-gray-700 dark:text-slate-300 rounded-lg text-sm font-medium hover:bg-gray-50 dark:hover:bg-slate-700 transition-colors">
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit" id="tagImportBtn" class="px-4 py-2 bg-primary text-white rounded-lg text-sm font-medium hover:bg-primary-dark transition-colors">
|
||||
<i class="fas fa-upload mr-1.5"></i>Import Tags
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% include 'partials/import-modal.twig' with {
|
||||
prefix: 'tag',
|
||||
title: 'Import Tags',
|
||||
action: '/tags/import',
|
||||
format_html: '<p class="text-xs text-gray-600 dark:text-slate-400">CSV columns: <code class="bg-white dark:bg-slate-700 px-1 rounded">name, color, description</code></p><p class="text-xs text-gray-600 dark:text-slate-400 mt-0.5">JSON: array of objects with same fields</p><p class="text-xs text-gray-500 dark:text-slate-400 mt-1">Tags that already exist will be skipped. Only your private tags are imported.</p>'
|
||||
} %}
|
||||
|
||||
<script>
|
||||
function toggleSelectAll(checkbox) {
|
||||
@@ -561,13 +510,12 @@ function getSelectedIds() {
|
||||
return [...new Set(ids)];
|
||||
}
|
||||
|
||||
function bulkDeleteTags() {
|
||||
async function bulkDeleteTags() {
|
||||
const ids = getSelectedIds();
|
||||
if (ids.length === 0) return;
|
||||
|
||||
if (!confirm(`Delete ${ids.length} tag(s)? This will remove them from all domains.`)) {
|
||||
return;
|
||||
}
|
||||
var ok = await confirmAction({ message: 'Delete ' + ids.length + ' tag(s)? This will remove them from all domains.' });
|
||||
if (!ok) return;
|
||||
|
||||
const form = document.createElement('form');
|
||||
form.method = 'POST';
|
||||
@@ -592,46 +540,15 @@ function bulkDeleteTags() {
|
||||
}
|
||||
|
||||
function transferTag(tagId, tagName) {
|
||||
const users = {{ users|default([])|json_encode|raw }};
|
||||
if (users.length === 0) {
|
||||
alert('No users available for transfer');
|
||||
return;
|
||||
}
|
||||
const escapeHtml = (s) => String(s).replace(/&/g,'&').replace(/"/g,'"').replace(/'/g,''').replace(/</g,'<').replace(/>/g,'>');
|
||||
const userOptions = users.map(user =>
|
||||
`<option value="${user.id}">${escapeHtml(user.username)} (${escapeHtml(user.full_name || 'No name')})</option>`
|
||||
).join('');
|
||||
const modal = document.createElement('div');
|
||||
modal.className = 'fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4';
|
||||
modal.innerHTML = `
|
||||
<div class="bg-white dark:bg-slate-800 rounded-lg shadow-xl w-full max-w-md p-6">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-1">Transfer Tag</h3>
|
||||
<p class="text-sm text-gray-600 dark:text-slate-400 mb-4">Transfer tag "${escapeHtml(tagName)}" to another user.</p>
|
||||
|
||||
<form method="POST" action="/tags/transfer">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<input type="hidden" name="tag_id" value="${tagId}">
|
||||
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-slate-300 mb-2">Transfer to User</label>
|
||||
<select name="target_user_id" required class="w-full px-3 py-2 border border-gray-300 dark:border-slate-600 rounded-lg focus:ring-2 focus:ring-primary focus:border-primary text-sm bg-white dark:bg-slate-700 text-gray-900 dark:text-white">
|
||||
<option value="">Select User</option>
|
||||
${userOptions}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-3">
|
||||
<button type="button" onclick="this.closest('.fixed').remove()" class="px-4 py-2 border border-gray-300 dark:border-slate-600 rounded-lg hover:bg-gray-50 dark:hover:bg-slate-700 text-sm font-medium text-gray-700 dark:text-slate-300">
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit" class="px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary-dark text-sm font-medium">
|
||||
Transfer
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
`;
|
||||
document.body.appendChild(modal);
|
||||
const esc = (s) => String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');
|
||||
openTransferModal({
|
||||
title: 'Transfer Tag',
|
||||
description: 'Transfer tag <strong>' + esc(tagName) + '</strong> to another user.',
|
||||
action: '/tags/transfer',
|
||||
fields: { tag_id: tagId },
|
||||
users: {{ users|default([])|json_encode|raw }},
|
||||
csrfToken: '{{ csrf_token() }}'
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener('click', function(e) {
|
||||
@@ -648,49 +565,15 @@ function bulkTransferTags() {
|
||||
alert('Please select tags to transfer');
|
||||
return;
|
||||
}
|
||||
|
||||
const users = {{ users|default([])|json_encode|raw }};
|
||||
if (users.length === 0) {
|
||||
alert('No users available for transfer');
|
||||
return;
|
||||
}
|
||||
|
||||
const userOptions = users.map(user =>
|
||||
`<option value="${user.id}">${user.username} (${user.full_name || 'No name'})</option>`
|
||||
).join('');
|
||||
|
||||
const modal = document.createElement('div');
|
||||
modal.className = 'fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4';
|
||||
modal.innerHTML = `
|
||||
<div class="bg-white dark:bg-slate-800 rounded-lg shadow-xl w-full max-w-md p-6">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-1">Transfer Tags</h3>
|
||||
<p class="text-sm text-gray-600 dark:text-slate-400 mb-4">Transfer ${ids.length} selected tag(s) to another user.</p>
|
||||
|
||||
<form method="POST" action="/tags/bulk-transfer">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
${ids.map(id => `<input type="hidden" name="tag_ids[]" value="${id}">`).join('')}
|
||||
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-slate-300 mb-2">Transfer to User</label>
|
||||
<select name="target_user_id" required class="w-full px-3 py-2 border border-gray-300 dark:border-slate-600 rounded-lg focus:ring-2 focus:ring-primary focus:border-primary text-sm bg-white dark:bg-slate-700 text-gray-900 dark:text-white">
|
||||
<option value="">Select User</option>
|
||||
${userOptions}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-3">
|
||||
<button type="button" onclick="this.closest('.fixed').remove()" class="px-4 py-2 border border-gray-300 dark:border-slate-600 rounded-lg hover:bg-gray-50 dark:hover:bg-slate-700 text-sm font-medium text-gray-700 dark:text-slate-300">
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit" class="px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary-dark text-sm font-medium">
|
||||
Transfer All
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
`;
|
||||
|
||||
document.body.appendChild(modal);
|
||||
openTransferModal({
|
||||
title: 'Transfer Tags',
|
||||
description: 'Transfer ' + ids.length + ' selected tag(s) to another user.',
|
||||
action: '/tags/bulk-transfer',
|
||||
fields: { 'tag_ids[]': ids },
|
||||
submitText: 'Transfer All',
|
||||
users: {{ users|default([])|json_encode|raw }},
|
||||
csrfToken: '{{ csrf_token() }}'
|
||||
});
|
||||
}
|
||||
|
||||
function openCreateModal() {
|
||||
@@ -716,8 +599,9 @@ function closeEditModal() {
|
||||
document.getElementById('editModal').classList.add('hidden');
|
||||
}
|
||||
|
||||
function deleteTag(id, name) {
|
||||
if (confirm(`Are you sure you want to delete the tag "${name}"? This will remove it from all domains.`)) {
|
||||
async function deleteTag(id, name) {
|
||||
var ok = await confirmAction({ message: 'Are you sure you want to delete the tag "' + name + '"? This will remove it from all domains.' });
|
||||
if (ok) {
|
||||
const form = document.createElement('form');
|
||||
form.method = 'POST';
|
||||
form.action = '/tags/delete';
|
||||
@@ -758,9 +642,9 @@ document.getElementById('editModal').addEventListener('click', function(e) {
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('importModal').addEventListener('click', function(e) {
|
||||
document.getElementById('tagImportModal').addEventListener('click', function(e) {
|
||||
if (e.target === this) {
|
||||
document.getElementById('importModal').classList.add('hidden');
|
||||
document.getElementById('tagImportModal').classList.add('hidden');
|
||||
}
|
||||
});
|
||||
|
||||
@@ -771,81 +655,6 @@ document.addEventListener('click', function(e) {
|
||||
}
|
||||
});
|
||||
|
||||
(function() {
|
||||
const dropzone = document.getElementById('tagDropzone');
|
||||
const fileInput = document.getElementById('tagFileInput');
|
||||
const content = document.getElementById('tagDropzoneContent');
|
||||
const fileInfo = document.getElementById('tagDropzoneFile');
|
||||
const fileName = document.getElementById('tagFileName');
|
||||
const fileSize = document.getElementById('tagFileSize');
|
||||
const removeBtn = document.getElementById('tagFileRemove');
|
||||
const form = document.getElementById('tagImportForm');
|
||||
const submitBtn = document.getElementById('tagImportBtn');
|
||||
|
||||
function formatSize(bytes) {
|
||||
if (bytes >= 1048576) return (bytes / 1048576).toFixed(1) + ' MB';
|
||||
if (bytes >= 1024) return (bytes / 1024).toFixed(1) + ' KB';
|
||||
return bytes + ' B';
|
||||
}
|
||||
|
||||
function showFile(file) {
|
||||
fileName.textContent = file.name;
|
||||
fileSize.textContent = formatSize(file.size);
|
||||
content.classList.add('hidden');
|
||||
fileInfo.classList.remove('hidden');
|
||||
dropzone.classList.remove('border-gray-300');
|
||||
dropzone.classList.add('border-primary', 'bg-primary/5');
|
||||
}
|
||||
|
||||
function resetDropzone() {
|
||||
fileInput.value = '';
|
||||
content.classList.remove('hidden');
|
||||
fileInfo.classList.add('hidden');
|
||||
dropzone.classList.add('border-gray-300');
|
||||
dropzone.classList.remove('border-primary', 'bg-primary/5');
|
||||
}
|
||||
|
||||
fileInput.addEventListener('change', function() {
|
||||
if (this.files.length) showFile(this.files[0]);
|
||||
});
|
||||
|
||||
removeBtn.addEventListener('click', function(e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
resetDropzone();
|
||||
});
|
||||
|
||||
['dragenter', 'dragover'].forEach(evt => {
|
||||
dropzone.addEventListener(evt, function(e) {
|
||||
e.preventDefault();
|
||||
dropzone.classList.add('border-primary', 'bg-primary/5');
|
||||
dropzone.classList.remove('border-gray-300');
|
||||
});
|
||||
});
|
||||
|
||||
['dragleave', 'drop'].forEach(evt => {
|
||||
dropzone.addEventListener(evt, function(e) {
|
||||
e.preventDefault();
|
||||
if (!fileInput.files.length) {
|
||||
dropzone.classList.remove('border-primary', 'bg-primary/5');
|
||||
dropzone.classList.add('border-gray-300');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
dropzone.addEventListener('drop', function(e) {
|
||||
e.preventDefault();
|
||||
const files = e.dataTransfer.files;
|
||||
if (files.length) {
|
||||
fileInput.files = files;
|
||||
showFile(files[0]);
|
||||
}
|
||||
});
|
||||
|
||||
form.addEventListener('submit', function() {
|
||||
submitBtn.disabled = true;
|
||||
submitBtn.innerHTML = '<i class="fas fa-spinner fa-spin mr-1.5"></i>Importing...';
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
{% include 'partials/transfer-modal.twig' %}
|
||||
{% endblock %}
|
||||
|
||||
@@ -210,16 +210,22 @@
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||
<div class="flex items-center justify-end space-x-2">
|
||||
<a href="/domains/{{ domain.id }}" class="text-blue-600 hover:text-blue-800" title="View">
|
||||
<a href="/domains/{{ domain.id }}" class="text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300" title="View">
|
||||
<i class="fas fa-eye"></i>
|
||||
</a>
|
||||
<form method="POST" action="/domains/{{ domain.id }}/refresh-whois" class="inline">
|
||||
{{ csrf_field() }}
|
||||
<button type="submit" class="text-green-600 hover:text-green-800" title="Refresh WHOIS">
|
||||
<button type="submit" class="text-green-600 hover:text-green-800 dark:text-green-400 dark:hover:text-green-300" title="Refresh WHOIS">
|
||||
<i class="fas fa-sync-alt"></i>
|
||||
</button>
|
||||
</form>
|
||||
<a href="/domains/{{ domain.id }}/edit?from=/tags/{{ tag.id }}" class="text-yellow-600 hover:text-yellow-800" title="Edit">
|
||||
{% if auth.isAdmin %}
|
||||
<button type="button" class="domain-transfer-btn text-indigo-600 hover:text-indigo-800 dark:text-indigo-400 dark:hover:text-indigo-300" title="Transfer Domain"
|
||||
data-domain-id="{{ domain.id }}" data-domain-name="{{ domain.domain_name }}">
|
||||
<i class="fas fa-exchange-alt"></i>
|
||||
</button>
|
||||
{% endif %}
|
||||
<a href="/domains/{{ domain.id }}/edit?from=/tags/{{ tag.id }}" class="text-yellow-600 hover:text-yellow-800 dark:text-yellow-400 dark:hover:text-yellow-300" title="Edit">
|
||||
<i class="fas fa-edit"></i>
|
||||
</a>
|
||||
</div>
|
||||
@@ -273,6 +279,12 @@
|
||||
<i class="fas fa-sync-alt mr-1"></i> Refresh WHOIS
|
||||
</button>
|
||||
</form>
|
||||
{% if auth.isAdmin %}
|
||||
<button type="button" class="domain-transfer-btn flex-1 px-3 py-1.5 bg-indigo-50 dark:bg-indigo-500/10 text-indigo-600 dark:text-indigo-400 rounded text-center text-sm hover:bg-indigo-100 dark:hover:bg-indigo-500/20 transition-colors"
|
||||
data-domain-id="{{ domain.id }}" data-domain-name="{{ domain.domain_name }}">
|
||||
<i class="fas fa-exchange-alt mr-1"></i> Transfer
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
@@ -348,4 +360,28 @@
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if auth.isAdmin %}
|
||||
<script>
|
||||
function transferDomain(domainId, domainName) {
|
||||
var esc = function(s) { return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>'); };
|
||||
openTransferModal({
|
||||
title: 'Transfer Domain',
|
||||
description: 'Transfer <strong>' + esc(domainName) + '</strong> to another user.',
|
||||
action: '/domains/transfer',
|
||||
fields: { domain_id: domainId },
|
||||
users: {{ users|default([])|json_encode|raw }},
|
||||
csrfToken: '{{ csrf_token() }}'
|
||||
});
|
||||
}
|
||||
document.addEventListener('click', function(e) {
|
||||
var btn = e.target.closest('.domain-transfer-btn');
|
||||
if (btn) {
|
||||
e.preventDefault();
|
||||
transferDomain(parseInt(btn.dataset.domainId, 10), btn.dataset.domainName || '');
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% include 'partials/transfer-modal.twig' %}
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
@@ -348,10 +348,10 @@
|
||||
<i class="fas fa-eye"></i>
|
||||
</a>
|
||||
{% if session.role is defined and session.role == 'admin' %}
|
||||
<a href="/tld-registry/{{ tld.id }}/refresh" class="text-green-600 hover:text-green-800" title="Refresh" onclick="return confirm('Refresh TLD data from IANA?')">
|
||||
<a href="/tld-registry/{{ tld.id }}/refresh" class="text-green-600 hover:text-green-800" title="Refresh" onclick="return confirmClick(event, 'Refresh TLD data from IANA?', { title: 'Refresh TLD', icon: 'fa-sync text-green-500', confirmText: 'Refresh', confirmClass: 'bg-green-600 hover:bg-green-700' })">
|
||||
<i class="fas fa-sync-alt"></i>
|
||||
</a>
|
||||
<a href="/tld-registry/{{ tld.id }}/toggle-active" class="text-orange-600 hover:text-orange-800" title="Toggle Status" onclick="return confirm('Toggle TLD status?')">
|
||||
<a href="/tld-registry/{{ tld.id }}/toggle-active" class="text-orange-600 hover:text-orange-800" title="Toggle Status" onclick="return confirmClick(event, 'Toggle TLD status?', { title: 'Toggle Status', icon: 'fa-toggle-on text-orange-500', confirmText: 'Toggle', confirmClass: 'bg-orange-600 hover:bg-orange-700' })">
|
||||
<i class="fas fa-power-off"></i>
|
||||
</a>
|
||||
{% endif %}
|
||||
@@ -421,7 +421,7 @@
|
||||
<i class="fas fa-eye mr-1"></i> View
|
||||
</a>
|
||||
{% if session.role is defined and session.role == 'admin' %}
|
||||
<a href="/tld-registry/{{ tld.id }}/refresh" class="flex-1 px-3 py-1.5 bg-green-50 dark:bg-green-500/10 text-green-600 dark:text-green-400 rounded text-center text-sm hover:bg-green-100 dark:hover:bg-green-500/20 transition-colors" onclick="return confirm('Refresh TLD data?')">
|
||||
<a href="/tld-registry/{{ tld.id }}/refresh" class="flex-1 px-3 py-1.5 bg-green-50 dark:bg-green-500/10 text-green-600 dark:text-green-400 rounded text-center text-sm hover:bg-green-100 dark:hover:bg-green-500/20 transition-colors" onclick="return confirmClick(event, 'Refresh TLD data?', { title: 'Refresh TLD', icon: 'fa-sync text-green-500', confirmText: 'Refresh', confirmClass: 'bg-green-600 hover:bg-green-700' })">
|
||||
<i class="fas fa-sync-alt mr-1"></i> Refresh
|
||||
</a>
|
||||
{% endif %}
|
||||
@@ -584,63 +584,12 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Import TLD Modal #}
|
||||
<div id="tldImportModal" class="hidden fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center p-4">
|
||||
<div class="bg-white dark:bg-slate-800 rounded-lg shadow-xl w-full max-w-md">
|
||||
<div class="px-6 py-4 border-b border-gray-200 dark:border-slate-700 flex items-center justify-between">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
<i class="fas fa-upload text-primary mr-2"></i>Import TLDs
|
||||
</h3>
|
||||
<button onclick="document.getElementById('tldImportModal').classList.add('hidden')" class="text-gray-400 dark:text-slate-500 hover:text-gray-600 dark:hover:text-slate-300">
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
<form method="POST" action="/tld-registry/import" enctype="multipart/form-data" id="tldImportForm">
|
||||
{{ csrf_field() }}
|
||||
<div class="p-6 space-y-4">
|
||||
{# Drag & Drop Zone #}
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-slate-300 mb-1.5">Select File</label>
|
||||
<div id="tldDropzone" class="relative border-2 border-dashed border-gray-300 dark:border-slate-600 rounded-lg p-6 text-center cursor-pointer transition-all hover:border-primary hover:bg-gray-50 dark:hover:bg-slate-700">
|
||||
<input type="file" name="import_file" accept=".csv,.json" required id="tldFileInput"
|
||||
class="absolute inset-0 w-full h-full opacity-0 cursor-pointer z-10">
|
||||
<div id="tldDropzoneContent">
|
||||
<i class="fas fa-cloud-upload-alt text-3xl text-gray-400 dark:text-slate-500 mb-2"></i>
|
||||
<p class="text-sm text-gray-600 dark:text-slate-400 font-medium">Drag & drop your file here</p>
|
||||
<p class="text-xs text-gray-400 dark:text-slate-500 my-1">or</p>
|
||||
<span class="inline-flex items-center px-3 py-1.5 bg-primary text-white text-xs rounded-lg font-medium">
|
||||
<i class="fas fa-folder-open mr-1.5"></i>Browse Files
|
||||
</span>
|
||||
<p class="mt-2.5 text-xs text-gray-400 dark:text-slate-500">CSV, JSON · Max {{ max_upload_size() }}</p>
|
||||
</div>
|
||||
<div id="tldDropzoneFile" class="hidden">
|
||||
<i class="fas fa-file-alt text-2xl text-primary mb-1.5"></i>
|
||||
<p class="text-sm font-medium text-gray-700 dark:text-slate-300" id="tldFileName"></p>
|
||||
<p class="text-xs text-gray-400 dark:text-slate-500" id="tldFileSize"></p>
|
||||
<button type="button" id="tldFileRemove" class="mt-1.5 text-xs text-red-500 hover:text-red-700 font-medium">
|
||||
<i class="fas fa-trash-alt mr-1"></i>Remove
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-blue-50 dark:bg-blue-500/10 border border-blue-200 dark:border-blue-500/30 rounded-lg p-3">
|
||||
<p class="text-xs text-gray-700 dark:text-slate-300 font-medium mb-1"><i class="fas fa-info-circle text-blue-500 mr-1"></i> Expected Format</p>
|
||||
<p class="text-xs text-gray-600 dark:text-slate-400">CSV columns: <code class="bg-white dark:bg-slate-800 px-1 rounded">tld, whois_server, rdap_servers, registry_url, is_active</code></p>
|
||||
<p class="text-xs text-gray-600 dark:text-slate-400 mt-0.5">JSON: array of objects with same fields</p>
|
||||
<p class="text-xs text-gray-500 dark:text-slate-400 mt-1">Existing TLDs will be updated. New TLDs will be created as active.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="px-6 py-4 border-t border-gray-200 dark:border-slate-700 bg-gray-50 dark:bg-slate-900 flex justify-end gap-2 rounded-b-lg">
|
||||
<button type="button" onclick="document.getElementById('tldImportModal').classList.add('hidden')" class="px-4 py-2 border border-gray-300 dark:border-slate-600 text-gray-700 dark:text-slate-300 rounded-lg text-sm font-medium hover:bg-gray-50 dark:hover:bg-slate-700 transition-colors">
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit" id="tldImportBtn" class="px-4 py-2 bg-primary text-white rounded-lg text-sm font-medium hover:bg-primary-dark transition-colors">
|
||||
<i class="fas fa-upload mr-1.5"></i>Import TLDs
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% include 'partials/import-modal.twig' with {
|
||||
prefix: 'tld',
|
||||
title: 'Import TLDs',
|
||||
action: '/tld-registry/import',
|
||||
format_html: '<p class="text-xs text-gray-600 dark:text-slate-400">CSV columns: <code class="bg-white dark:bg-slate-800 px-1 rounded">tld, whois_server, rdap_servers, registry_url, is_active</code></p><p class="text-xs text-gray-600 dark:text-slate-400 mt-0.5">JSON: array of objects with same fields</p><p class="text-xs text-gray-500 dark:text-slate-400 mt-1">Existing TLDs will be updated. New TLDs will be created as active.</p>'
|
||||
} %}
|
||||
|
||||
<script>
|
||||
function toggleAllCheckboxes(selectAllCheckbox) {
|
||||
@@ -691,14 +640,15 @@ function clearSelection() {
|
||||
updateSelectedCount();
|
||||
}
|
||||
|
||||
function confirmBulkDelete() {
|
||||
async function confirmBulkDelete() {
|
||||
const checkboxes = document.querySelectorAll('.tld-checkbox:checked');
|
||||
if (checkboxes.length === 0) {
|
||||
alert('Please select TLDs to delete');
|
||||
return;
|
||||
}
|
||||
|
||||
if (confirm(`Are you sure you want to delete ${checkboxes.length} selected TLD(s)? This action cannot be undone.`)) {
|
||||
var ok = await confirmAction({ message: 'Are you sure you want to delete ' + checkboxes.length + ' selected TLD(s)? This action cannot be undone.' });
|
||||
if (ok) {
|
||||
const form = document.getElementById('bulk-delete-form');
|
||||
checkboxes.forEach(checkbox => {
|
||||
const input = document.createElement('input');
|
||||
@@ -759,84 +709,6 @@ document.addEventListener('keydown', function(e) {
|
||||
}
|
||||
});
|
||||
|
||||
(function() {
|
||||
const dropzone = document.getElementById('tldDropzone');
|
||||
const fileInput = document.getElementById('tldFileInput');
|
||||
const content = document.getElementById('tldDropzoneContent');
|
||||
const fileInfo = document.getElementById('tldDropzoneFile');
|
||||
const fileName = document.getElementById('tldFileName');
|
||||
const fileSize = document.getElementById('tldFileSize');
|
||||
const removeBtn = document.getElementById('tldFileRemove');
|
||||
const form = document.getElementById('tldImportForm');
|
||||
const submitBtn = document.getElementById('tldImportBtn');
|
||||
|
||||
if (!dropzone) return;
|
||||
|
||||
function formatSize(bytes) {
|
||||
if (bytes >= 1048576) return (bytes / 1048576).toFixed(1) + ' MB';
|
||||
if (bytes >= 1024) return (bytes / 1024).toFixed(1) + ' KB';
|
||||
return bytes + ' B';
|
||||
}
|
||||
|
||||
function showFile(file) {
|
||||
fileName.textContent = file.name;
|
||||
fileSize.textContent = formatSize(file.size);
|
||||
content.classList.add('hidden');
|
||||
fileInfo.classList.remove('hidden');
|
||||
dropzone.classList.remove('border-gray-300');
|
||||
dropzone.classList.add('border-primary', 'bg-primary/5');
|
||||
}
|
||||
|
||||
function resetDropzone() {
|
||||
fileInput.value = '';
|
||||
content.classList.remove('hidden');
|
||||
fileInfo.classList.add('hidden');
|
||||
dropzone.classList.add('border-gray-300');
|
||||
dropzone.classList.remove('border-primary', 'bg-primary/5');
|
||||
}
|
||||
|
||||
fileInput.addEventListener('change', function() {
|
||||
if (this.files.length) showFile(this.files[0]);
|
||||
});
|
||||
|
||||
removeBtn.addEventListener('click', function(e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
resetDropzone();
|
||||
});
|
||||
|
||||
['dragenter', 'dragover'].forEach(evt => {
|
||||
dropzone.addEventListener(evt, function(e) {
|
||||
e.preventDefault();
|
||||
dropzone.classList.add('border-primary', 'bg-primary/5');
|
||||
dropzone.classList.remove('border-gray-300');
|
||||
});
|
||||
});
|
||||
|
||||
['dragleave', 'drop'].forEach(evt => {
|
||||
dropzone.addEventListener(evt, function(e) {
|
||||
e.preventDefault();
|
||||
if (!fileInput.files.length) {
|
||||
dropzone.classList.remove('border-primary', 'bg-primary/5');
|
||||
dropzone.classList.add('border-gray-300');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
dropzone.addEventListener('drop', function(e) {
|
||||
e.preventDefault();
|
||||
const files = e.dataTransfer.files;
|
||||
if (files.length) {
|
||||
fileInput.files = files;
|
||||
showFile(files[0]);
|
||||
}
|
||||
});
|
||||
|
||||
form.addEventListener('submit', function() {
|
||||
submitBtn.disabled = true;
|
||||
submitBtn.innerHTML = '<i class="fas fa-spinner fa-spin mr-1.5"></i>Importing...';
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
@@ -21,11 +21,11 @@
|
||||
</div>
|
||||
<div class="flex gap-2 items-center">
|
||||
{% if session.role is defined and session.role == 'admin' %}
|
||||
<a href="/tld-registry/{{ tld.id }}/refresh" class="inline-flex items-center justify-center px-3 py-2 bg-green-600 text-white text-xs rounded-lg hover:bg-green-700 transition-colors font-medium min-w-[80px] h-[32px]" onclick="return confirm('Refresh TLD data from IANA?')">
|
||||
<a href="/tld-registry/{{ tld.id }}/refresh" class="inline-flex items-center justify-center px-3 py-2 bg-green-600 text-white text-xs rounded-lg hover:bg-green-700 transition-colors font-medium min-w-[80px] h-[32px]" onclick="return confirmClick(event, 'Refresh TLD data from IANA?', { title: 'Refresh TLD', icon: 'fa-sync text-green-500', confirmText: 'Refresh', confirmClass: 'bg-green-600 hover:bg-green-700' })">
|
||||
<i class="fas fa-sync-alt mr-1.5"></i>
|
||||
Refresh
|
||||
</a>
|
||||
<a href="/tld-registry/{{ tld.id }}/toggle-active" class="inline-flex items-center justify-center px-3 py-2 bg-orange-600 text-white text-xs rounded-lg hover:bg-orange-700 transition-colors font-medium min-w-[80px] h-[32px]" onclick="return confirm('Toggle TLD status?')">
|
||||
<a href="/tld-registry/{{ tld.id }}/toggle-active" class="inline-flex items-center justify-center px-3 py-2 bg-orange-600 text-white text-xs rounded-lg hover:bg-orange-700 transition-colors font-medium min-w-[80px] h-[32px]" onclick="return confirmClick(event, 'Toggle TLD status?', { title: 'Toggle Status', icon: 'fa-toggle-on text-orange-500', confirmText: 'Toggle', confirmClass: 'bg-orange-600 hover:bg-orange-700' })">
|
||||
<i class="fas fa-power-off mr-1.5"></i>
|
||||
Toggle
|
||||
</a>
|
||||
@@ -215,13 +215,13 @@
|
||||
</div>
|
||||
<div class="p-4 space-y-2">
|
||||
{% if session.role is defined and session.role == 'admin' %}
|
||||
<a href="/tld-registry/{{ tld.id }}/refresh" class="flex items-center p-3 border border-gray-200 dark:border-slate-700 hover:border-green-500 hover:bg-green-50 dark:hover:bg-green-500/10 rounded-lg transition-all duration-200 group" onclick="return confirm('Refresh TLD data from IANA?')">
|
||||
<a href="/tld-registry/{{ tld.id }}/refresh" class="flex items-center p-3 border border-gray-200 dark:border-slate-700 hover:border-green-500 hover:bg-green-50 dark:hover:bg-green-500/10 rounded-lg transition-all duration-200 group" onclick="return confirmClick(event, 'Refresh TLD data from IANA?', { title: 'Refresh TLD', icon: 'fa-sync text-green-500', confirmText: 'Refresh', confirmClass: 'bg-green-600 hover:bg-green-700' })">
|
||||
<div class="w-9 h-9 bg-green-50 dark:bg-green-500/10 group-hover:bg-green-500 rounded-lg flex items-center justify-center group-hover:text-white text-green-600 dark:text-green-400 transition-colors duration-200">
|
||||
<i class="fas fa-sync-alt text-sm"></i>
|
||||
</div>
|
||||
<span class="ml-3 text-sm font-medium text-gray-700 dark:text-slate-300 group-hover:text-green-700 dark:group-hover:text-green-400">Refresh from IANA</span>
|
||||
</a>
|
||||
<a href="/tld-registry/{{ tld.id }}/toggle-active" class="flex items-center p-3 border border-gray-200 dark:border-slate-700 hover:border-orange-500 hover:bg-orange-50 dark:hover:bg-orange-500/10 rounded-lg transition-all duration-200 group" onclick="return confirm('Toggle TLD status?')">
|
||||
<a href="/tld-registry/{{ tld.id }}/toggle-active" class="flex items-center p-3 border border-gray-200 dark:border-slate-700 hover:border-orange-500 hover:bg-orange-50 dark:hover:bg-orange-500/10 rounded-lg transition-all duration-200 group" onclick="return confirmClick(event, 'Toggle TLD status?', { title: 'Toggle Status', icon: 'fa-toggle-on text-orange-500', confirmText: 'Toggle', confirmClass: 'bg-orange-600 hover:bg-orange-700' })">
|
||||
<div class="w-9 h-9 bg-orange-50 dark:bg-orange-500/10 group-hover:bg-orange-500 rounded-lg flex items-center justify-center group-hover:text-white text-orange-600 dark:text-orange-400 transition-colors duration-200">
|
||||
<i class="fas fa-power-off text-sm"></i>
|
||||
</div>
|
||||
|
||||
@@ -396,7 +396,7 @@ function getSelectedUserIds() {
|
||||
return Array.from(checkboxes).map(cb => cb.value);
|
||||
}
|
||||
|
||||
function bulkToggleStatus(action) {
|
||||
async function bulkToggleStatus(action) {
|
||||
const userIds = getSelectedUserIds();
|
||||
|
||||
if (userIds.length === 0) {
|
||||
@@ -405,9 +405,8 @@ function bulkToggleStatus(action) {
|
||||
}
|
||||
|
||||
const actionText = action === 'active' ? 'activate' : 'deactivate';
|
||||
if (!confirm(`Are you sure you want to ${actionText} ${userIds.length} user(s)?`)) {
|
||||
return;
|
||||
}
|
||||
var ok = await confirmAction({ message: 'Are you sure you want to ' + actionText + ' ' + userIds.length + ' user(s)?', title: actionText.charAt(0).toUpperCase() + actionText.slice(1) + ' Users', icon: action === 'active' ? 'fa-user-check text-green-500' : 'fa-user-slash text-orange-500' });
|
||||
if (!ok) return;
|
||||
|
||||
const form = document.createElement('form');
|
||||
form.method = 'POST';
|
||||
@@ -450,10 +449,9 @@ function toggleUserStatus(userId) {
|
||||
form.submit();
|
||||
}
|
||||
|
||||
function deleteUser(userId) {
|
||||
if (!confirm('Are you sure you want to delete this user? This action cannot be undone.')) {
|
||||
return;
|
||||
}
|
||||
async function deleteUser(userId) {
|
||||
var ok = await confirmAction({ message: 'Are you sure you want to delete this user? This action cannot be undone.' });
|
||||
if (!ok) return;
|
||||
|
||||
const form = document.createElement('form');
|
||||
form.method = 'POST';
|
||||
@@ -469,7 +467,7 @@ function deleteUser(userId) {
|
||||
form.submit();
|
||||
}
|
||||
|
||||
function bulkDeleteUsers() {
|
||||
async function bulkDeleteUsers() {
|
||||
const userIds = getSelectedUserIds();
|
||||
|
||||
if (userIds.length === 0) {
|
||||
@@ -477,9 +475,8 @@ function bulkDeleteUsers() {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!confirm(`Are you sure you want to delete ${userIds.length} user(s)? This action cannot be undone.`)) {
|
||||
return;
|
||||
}
|
||||
var ok = await confirmAction({ message: 'Are you sure you want to delete ' + userIds.length + ' user(s)? This action cannot be undone.' });
|
||||
if (!ok) return;
|
||||
|
||||
const form = document.createElement('form');
|
||||
form.method = 'POST';
|
||||
|
||||
@@ -48,12 +48,12 @@
|
||||
<form method="POST" action="/users/{{ user.id }}/toggle-status" class="inline">
|
||||
{{ csrf_field() }}
|
||||
{% if isActive %}
|
||||
<button type="submit" onclick="return confirm('Deactivate this user?')" class="px-4 py-2 bg-orange-600 text-white rounded-lg hover:bg-orange-700 transition-colors text-sm font-medium">
|
||||
<button type="submit" onclick="return confirmClick(event, 'Deactivate this user?', { title: 'Deactivate', icon: 'fa-user-slash text-orange-500', confirmClass: 'bg-orange-600 hover:bg-orange-700' })" class="px-4 py-2 bg-orange-600 text-white rounded-lg hover:bg-orange-700 transition-colors text-sm font-medium">
|
||||
<i class="fas fa-user-slash mr-2"></i>
|
||||
Deactivate
|
||||
</button>
|
||||
{% else %}
|
||||
<button type="submit" onclick="return confirm('Activate this user?')" class="px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors text-sm font-medium">
|
||||
<button type="submit" onclick="return confirmClick(event, 'Activate this user?', { title: 'Activate', icon: 'fa-user-check text-green-500', confirmClass: 'bg-green-600 hover:bg-green-700' })" class="px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors text-sm font-medium">
|
||||
<i class="fas fa-user-check mr-2"></i>
|
||||
Activate
|
||||
</button>
|
||||
@@ -61,7 +61,7 @@
|
||||
</form>
|
||||
<form method="POST" action="/users/{{ user.id }}/delete" class="inline">
|
||||
{{ csrf_field() }}
|
||||
<button type="submit" onclick="return confirm('Are you sure you want to delete this user? This action cannot be undone.')" class="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors text-sm font-medium">
|
||||
<button type="submit" onclick="return confirmClick(event, 'Are you sure you want to delete this user? This action cannot be undone.')" class="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors text-sm font-medium">
|
||||
<i class="fas fa-trash mr-2"></i>
|
||||
Delete
|
||||
</button>
|
||||
@@ -529,8 +529,15 @@
|
||||
{{ domain.statusText }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-600 dark:text-slate-400">
|
||||
{{ domain.group_name|default('—') }}
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm">
|
||||
{% if domain.group_name %}
|
||||
<span class="inline-flex items-center px-3 py-1 rounded-full text-xs font-medium bg-blue-100 dark:bg-blue-500/20 text-blue-800 dark:text-blue-400">
|
||||
<i class="fas fa-bell mr-1"></i>
|
||||
{{ domain.group_name }}
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="text-gray-400 dark:text-slate-500">No Group</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
|
||||
Reference in New Issue
Block a user