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:
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>
|
||||
Reference in New Issue
Block a user