feat(ucp): NexusPBX v2.5.0 Portail Utilisateur isolé (Refs #4)

This commit is contained in:
sorcier committed 2026-11-11 18:47:25 +01:00
1 parent 7922e338e3
commit 72e43f1a9a
18 files changed
+3499 -62

No files matched your search

+37 -4
View File
@@ -13,6 +13,7 @@ require_once __DIR__ . '/../src/FirewallManager.php';
require_once __DIR__ . '/../src/PhonebookManager.php';
require_once __DIR__ . '/../src/CdrManager.php';
require_once __DIR__ . '/../src/ClusterManager.php';
require_once __DIR__ . '/../src/UcpManager.php';
// Autoloader PSR-4 automatique pour toutes les classes du namespace App
spl_autoload_register(function (string $class): void {
@@ -36,6 +37,7 @@ use App\FirewallManager;
use App\PhonebookManager;
use App\CdrManager;
use App\ClusterManager;
use App\UcpManager;
header('Content-Type: application/json; charset=utf-8');
header('Cache-Control: no-store, no-cache, must-revalidate');
@@ -96,10 +98,7 @@ try {
]);
} else {
// Journalisation de sécurité pour Fail2ban (jail nexus-web)
$clientIp = $_SERVER['HTTP_X_FORWARDED_FOR'] ?? $_SERVER['REMOTE_ADDR'] ?? '127.0.0.1';
if (str_contains($clientIp, ',')) {
$clientIp = trim(explode(',', $clientIp)[0]);
}
$clientIp = FirewallManager::resolveClientIp();
$logMsg = date('[Y-m-d H:i:s] ') . "[NEXUS-WEB-AUTH-FAIL]: " . $clientIp . " (username: " . ($username ?: 'vide') . ")\n";
@file_put_contents('/var/log/nexus_security.log', $logMsg, FILE_APPEND | LOCK_EX);
@@ -210,6 +209,7 @@ try {
'backups_count' => count(BackupManager::listBackups($db)),
'phonebook_contacts' => PhonebookManager::getContacts($db),
'phonebook_groups' => PhonebookManager::getGroups($db),
'ucp_users' => UcpManager::listUsers($db),
'metrics' => SystemMetrics::collect($db),
'previews' => [
'pjsip' => $generator->generatePjsipConf(),
@@ -273,6 +273,13 @@ try {
@chgrp($webLog, 'asterisk');
@chmod($webLog, 0664);
}
$ucpLog = FirewallManager::UCP_SECURITY_LOG;
if (file_exists($ucpLog)) {
@file_put_contents($ucpLog, '');
@chown($ucpLog, 'www-data');
@chgrp($ucpLog, 'asterisk');
@chmod($ucpLog, 0664);
}
@unlink('/tmp/nexus_fw_status_cache.json');
exec('sudo /usr/bin/systemctl restart fail2ban 2>&1 || sudo /usr/bin/fail2ban-client reload 2>&1');
@@ -2446,6 +2453,32 @@ try {
echo json_encode($res);
break;
case 'get_ucp_users':
echo json_encode([
'success' => true,
'users' => UcpManager::listUsers($db),
]);
break;
case 'save_ucp_user':
$users = UcpManager::saveUser($db, $payload);
echo json_encode([
'success' => true,
'users' => $users,
'message' => 'Compte utilisateur UCP enregistré avec succès.',
]);
break;
case 'delete_ucp_user':
$uid = (int)($payload['id'] ?? 0);
$users = UcpManager::deleteUser($db, $uid);
echo json_encode([
'success' => true,
'users' => $users,
'message' => 'Compte utilisateur UCP supprimé.',
]);
break;
case 'get_cdr':
$filters = [
'limit' => (int)($payload['limit'] ?? $_GET['limit'] ?? 100),
+251 -2
View File
@@ -211,6 +211,7 @@ function renderAll() {
renderFeatureCodes();
renderSipSettings();
renderCertificates();
renderUcpTab();
renderSettingsAndDiagnostics();
}
@@ -356,6 +357,8 @@ function renderSummaryCards() {
if (pagingBadge) pagingBadge.textContent = (appState.paging_groups || []).length;
const pbBadge = document.getElementById('nav-badge-phonebook');
if (pbBadge) pbBadge.textContent = (appState.phonebook_contacts || []).length;
const ucpBadge = document.getElementById('nav-badge-ucp');
if (ucpBadge) ucpBadge.textContent = (appState.ucp_users || []).length;
document.getElementById('nav-badge-trunks').textContent = trks.length;
const obBadge = document.getElementById('nav-badge-outbound');
if (obBadge) obBadge.textContent = obRoutes.length;
@@ -3629,6 +3632,7 @@ const PAGE_TITLES = {
system: 'Paramètres Globaux, Diagnostic Debian & Fichiers .conf',
backups: 'Sauvegardes & Restauration Système (Disaster Recovery)',
firewall: 'Pare-feu Réseau & Sécurité Téléphonie (Fail2ban & NFTables)',
ucp: 'Portail Utilisateur (UCP) — Comptes & Droits',
cluster: 'Haute Disponibilité & Redondance (Cluster HA Keepalived)'
};
@@ -3709,6 +3713,11 @@ function switchTab(tabId) {
loadCluster();
}
// Si onglet Portail Utilisateur (UCP), actualiser l'affichage
if (tabId === 'ucp') {
renderUcpTab();
}
toggleMobileSidebar(false);
}
@@ -4346,19 +4355,29 @@ document.addEventListener('DOMContentLoaded', () => {
const btn = document.getElementById('btn-save-firewall-settings');
if (btn) btn.disabled = true;
const rawUcpHttps = f.elements['ucp_https_port']?.value;
const rawUcpHttp = f.elements['ucp_http_port']?.value;
const payload = {
firewall_enabled: f.elements['firewall_enabled']?.checked ? 1 : 0,
admin_https_port: Number(f.elements['admin_https_port']?.value || 8443),
certbot_strict_port80: f.elements['certbot_strict_port80']?.checked ? 1 : 0,
fail2ban_sip_maxretry: Number(f.elements['fail2ban_sip_maxretry']?.value || 3),
fail2ban_sip_bantime: Number(f.elements['fail2ban_sip_bantime']?.value || 3600),
fail2ban_whitelist_ips: (f.elements['fail2ban_whitelist_ips']?.value || '').trim()
fail2ban_whitelist_ips: (f.elements['fail2ban_whitelist_ips']?.value || '').trim(),
ucp_enabled: f.elements['ucp_enabled']?.checked ? 1 : 0,
ucp_https_port: rawUcpHttps !== '' && rawUcpHttps !== undefined ? Number(rawUcpHttps) : 8444,
ucp_http_port: rawUcpHttp !== '' && rawUcpHttp !== undefined ? Number(rawUcpHttp) : 8084,
fail2ban_ucp_maxretry: Number(f.elements['fail2ban_ucp_maxretry']?.value || 3),
fail2ban_ucp_bantime: Number(f.elements['fail2ban_ucp_bantime']?.value || 3600)
};
try {
const res = await apiCall('save_firewall_settings', payload);
showToast(res.message, 'success');
await loadFirewallStatus();
if (typeof loadState === 'function') {
await loadState();
}
} catch (err) {
showToast("Erreur application sécurité : " + err.message, 'error');
} finally {
@@ -5379,7 +5398,12 @@ function renderFirewall() {
`;
} else {
tbody.innerHTML = banned.map(b => {
const jailClass = b.jail === 'asterisk' ? 'bg-cyan-500/20 text-cyan-300 border border-cyan-500/30' : 'bg-purple-500/20 text-purple-300 border border-purple-500/30';
let jailClass = 'bg-purple-500/20 text-purple-300 border border-purple-500/30';
if (b.jail === 'asterisk') {
jailClass = 'bg-cyan-500/20 text-cyan-300 border border-cyan-500/30';
} else if (b.jail === 'nexus-ucp') {
jailClass = 'bg-sky-500/20 text-sky-300 border border-sky-500/30';
}
return `
<tr class="hover:bg-anthracite-750 transition">
<td class="px-4 py-3 font-mono font-bold text-white text-sm">
@@ -5424,6 +5448,21 @@ function renderFirewall() {
if (form.elements['fail2ban_whitelist_ips'] && document.activeElement !== form.elements['fail2ban_whitelist_ips']) {
form.elements['fail2ban_whitelist_ips'].value = s.fail2ban_whitelist_ips || '';
}
if (form.elements['ucp_enabled']) {
form.elements['ucp_enabled'].checked = Number(s.ucp_enabled ?? 1) === 1;
}
if (form.elements['ucp_https_port'] && document.activeElement !== form.elements['ucp_https_port']) {
form.elements['ucp_https_port'].value = s.ucp_https_port !== undefined ? s.ucp_https_port : 8444;
}
if (form.elements['ucp_http_port'] && document.activeElement !== form.elements['ucp_http_port']) {
form.elements['ucp_http_port'].value = s.ucp_http_port !== undefined ? s.ucp_http_port : 8084;
}
if (form.elements['fail2ban_ucp_maxretry']) {
form.elements['fail2ban_ucp_maxretry'].value = s.fail2ban_ucp_maxretry || 3;
}
if (form.elements['fail2ban_ucp_bantime']) {
form.elements['fail2ban_ucp_bantime'].value = s.fail2ban_ucp_bantime || 3600;
}
}
}
@@ -6798,4 +6837,214 @@ function copyClusterSecret() {
});
}
// =========================================================================
// GESTION DES COMPTES PORTAIL UTILISATEUR (UCP)
// =========================================================================
function renderUcpTab() {
const s = appState.settings || {};
const ucpEnabled = Number(s.ucp_enabled ?? 1) === 1;
const httpsPort = Number(s.ucp_https_port ?? 8444);
const httpPort = Number(s.ucp_http_port ?? 8084);
const host = window.location.hostname || 'localhost';
const statusBadge = document.getElementById('ucp-service-status-badge');
if (statusBadge) {
if (!ucpEnabled || (httpsPort <= 0 && httpPort <= 0)) {
statusBadge.textContent = 'Portail désactivé';
statusBadge.className = 'px-2 py-0.5 rounded-full text-[11px] font-semibold bg-rose-500/15 text-rose-300 border border-rose-500/30';
} else {
statusBadge.textContent = 'En ligne & Isolé';
statusBadge.className = 'px-2 py-0.5 rounded-full text-[11px] font-semibold bg-emerald-500/15 text-emerald-300 border border-emerald-500/30';
}
}
const urlsBox = document.getElementById('ucp-access-urls');
if (urlsBox) {
if (!ucpEnabled) {
urlsBox.innerHTML = '<span class="text-slate-500 italic">Activez l’UCP dans Pare-feu & Sécurité pour exposer le portail.</span>';
} else {
const links = [];
if (httpsPort > 0) {
const url = `https://${host}:${httpsPort}/`;
links.push(`<a href="${escapeHtml(url)}" target="_blank" rel="noopener" class="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg bg-emerald-500/10 hover:bg-emerald-500/20 text-emerald-300 border border-emerald-500/30 font-mono transition">🔒 ${escapeHtml(url)}</a>`);
}
if (httpPort > 0) {
const url = `http://${host}:${httpPort}/`;
links.push(`<a href="${escapeHtml(url)}" target="_blank" rel="noopener" class="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg bg-sky-500/10 hover:bg-sky-500/20 text-sky-300 border border-sky-500/30 font-mono transition">🌐 ${escapeHtml(url)} (Reverse Proxy)</a>`);
}
urlsBox.innerHTML = links.length ? links.join('') : '<span class="text-amber-400">Aucun port d’écoute configuré.</span>';
}
}
renderUcpUsersTable();
}
function renderUcpUsersTable() {
const tbody = document.getElementById('ucp-users-table-body');
if (!tbody) return;
const users = appState.ucp_users || [];
const extMap = new Map((appState.extensions || []).map(e => [String(e.extension), e]));
if (users.length === 0) {
tbody.innerHTML = `
<tr>
<td colspan="6" class="px-5 py-10 text-center text-slate-400">
<div class="flex flex-col items-center gap-2">
<span class="text-sm font-medium text-slate-300">Aucun utilisateur UCP créé pour le moment.</span>
<span class="text-xs text-slate-500">Cliquez sur « Nouvel Utilisateur UCP » pour ouvrir un accès employé à sa messagerie vocale, ses renvois et l’annuaire.</span>
</div>
</td>
</tr>
`;
return;
}
tbody.innerHTML = users.map(u => {
const exts = Array.isArray(u.extensions) ? u.extensions : [];
const extChips = exts.length
? exts.map(num => {
const info = extMap.get(String(num));
const title = info ? `${num} — ${info.caller_id_name || ''}` : String(num);
return `<span title="${escapeHtml(title)}" class="px-2 py-0.5 rounded-md bg-sky-500/15 text-sky-300 border border-sky-500/30 font-mono text-[11px] font-semibold">${escapeHtml(String(num))}</span>`;
}).join(' ')
: '<span class="text-slate-500 italic">Aucune extension</span>';
const isEnabled = Number(u.enabled) === 1;
const statusPill = isEnabled
? '<span class="px-2 py-0.5 rounded-full text-[11px] font-semibold bg-emerald-500/15 text-emerald-300 border border-emerald-500/30">Actif</span>'
: '<span class="px-2 py-0.5 rounded-full text-[11px] font-semibold bg-rose-500/15 text-rose-300 border border-rose-500/30">Désactivé</span>';
const lastLogin = u.last_login_at ? escapeHtml(u.last_login_at) : '<span class="text-slate-500">Jamais connecté</span>';
return `
<tr class="hover:bg-anthracite-800/40 transition">
<td class="px-5 py-3.5 font-mono font-bold text-white">${escapeHtml(u.username || '')}</td>
<td class="px-5 py-3.5">
<div class="font-semibold text-slate-200">${escapeHtml(u.display_name || u.username || '')}</div>
<div class="text-[11px] text-slate-400">${u.email ? escapeHtml(u.email) : '<span class="text-slate-600">—</span>'}</div>
</td>
<td class="px-5 py-3.5">
<div class="flex flex-wrap gap-1.5">${extChips}</div>
</td>
<td class="px-5 py-3.5">${statusPill}</td>
<td class="px-5 py-3.5 text-slate-400">${lastLogin}</td>
<td class="px-5 py-3.5 text-right whitespace-nowrap">
<button type="button" onclick="openUcpUserModal(${Number(u.id)})"
class="px-2.5 py-1 rounded-lg bg-anthracite-800 hover:bg-sky-600/20 text-slate-300 hover:text-sky-300 border border-anthracite-700 hover:border-sky-500/40 text-xs font-semibold transition mr-1.5">
Modifier
</button>
<button type="button" onclick="deleteUcpUser(${Number(u.id)}, '${escapeHtml(u.username || '')}')"
class="px-2.5 py-1 rounded-lg bg-rose-500/10 hover:bg-rose-600 text-rose-300 hover:text-white border border-rose-500/30 text-xs font-semibold transition">
Supprimer
</button>
</td>
</tr>
`;
}).join('');
}
function openUcpUserModal(userId = 0) {
const modal = document.getElementById('modal-ucp-user');
if (!modal) return;
const users = appState.ucp_users || [];
const user = userId ? users.find(u => Number(u.id) === Number(userId)) : null;
document.getElementById('ucp-user-id').value = user ? Number(user.id) : 0;
document.getElementById('ucp-modal-title').textContent = user ? `Modifier l'utilisateur UCP (${user.username})` : 'Nouvel utilisateur UCP';
document.getElementById('ucp-user-username').value = user ? (user.username || '') : '';
document.getElementById('ucp-user-display-name').value = user ? (user.display_name || '') : '';
document.getElementById('ucp-user-email').value = user ? (user.email || '') : '';
document.getElementById('ucp-user-password').value = '';
document.getElementById('ucp-user-password').required = !user;
document.getElementById('ucp-password-hint').textContent = user ? '(laisser vide pour conserver)' : '(min. 6 car., requis)';
document.getElementById('ucp-user-enabled').checked = user ? Number(user.enabled) === 1 : true;
const selectedExts = new Set((user?.extensions || []).map(String));
const container = document.getElementById('ucp-user-extensions-container');
const exts = [...(appState.extensions || [])].sort((a, b) => Number(a.extension) - Number(b.extension));
if (container) {
if (exts.length === 0) {
container.innerHTML = '<div class="col-span-full text-xs text-slate-500 py-2">Aucune extension SIP configurée sur le PBX.</div>';
} else {
container.innerHTML = exts.map(ext => {
const num = String(ext.extension);
const checked = selectedExts.has(num) ? 'checked' : '';
return `
<label class="flex items-center gap-2.5 p-2 rounded-lg bg-anthracite-900 border border-anthracite-800 hover:border-sky-500/40 cursor-pointer select-none transition">
<input type="checkbox" name="ucp_ext_choice" value="${escapeHtml(num)}" ${checked}
class="w-4 h-4 rounded border-anthracite-600 bg-anthracite-950 text-sky-500 focus:ring-sky-500">
<div class="min-w-0">
<div class="text-xs font-bold text-white font-mono">${escapeHtml(num)}</div>
<div class="text-[11px] text-slate-400 truncate">${escapeHtml(ext.caller_id_name || 'Sans nom')}</div>
</div>
</label>
`;
}).join('');
}
}
modal.classList.remove('hidden');
modal.classList.add('flex');
}
function closeUcpUserModal() {
const modal = document.getElementById('modal-ucp-user');
if (!modal) return;
modal.classList.add('hidden');
modal.classList.remove('flex');
}
async function saveUcpUser(event) {
event.preventDefault();
const btn = document.getElementById('btn-save-ucp-user');
if (btn) btn.disabled = true;
const checkedExts = Array.from(document.querySelectorAll('#ucp-user-extensions-container input[name="ucp_ext_choice"]:checked'))
.map(el => el.value);
const payload = {
id: Number(document.getElementById('ucp-user-id').value || 0),
username: (document.getElementById('ucp-user-username').value || '').trim(),
display_name: (document.getElementById('ucp-user-display-name').value || '').trim(),
email: (document.getElementById('ucp-user-email').value || '').trim(),
password: document.getElementById('ucp-user-password').value || '',
enabled: document.getElementById('ucp-user-enabled').checked ? 1 : 0,
extensions: checkedExts
};
try {
const res = await apiCall('save_ucp_user', payload);
appState.ucp_users = res.ucp_users || [];
renderUcpTab();
const ucpBadge = document.getElementById('nav-badge-ucp');
if (ucpBadge) ucpBadge.textContent = appState.ucp_users.length;
closeUcpUserModal();
showToast(res.message || 'Utilisateur UCP enregistré.', 'success');
} catch (err) {
showToast(err.message || "Erreur lors de l'enregistrement de l'utilisateur UCP.", 'error');
} finally {
if (btn) btn.disabled = false;
}
}
async function deleteUcpUser(userId, username) {
if (!confirm(`Supprimer définitivement le compte UCP « ${username} » ?`)) {
return;
}
try {
const res = await apiCall('delete_ucp_user', { id: Number(userId) });
appState.ucp_users = res.ucp_users || [];
renderUcpTab();
const ucpBadge = document.getElementById('nav-badge-ucp');
if (ucpBadge) ucpBadge.textContent = appState.ucp_users.length;
showToast(res.message || 'Utilisateur UCP supprimé.', 'success');
} catch (err) {
showToast(err.message || "Erreur lors de la suppression de l'utilisateur UCP.", 'error');
}
}
+206
View File
@@ -338,6 +338,11 @@ $isDevEnv = file_exists(__DIR__ . '/../.dev')
<span>• Annuaire d'Entreprise</span>
<span id="nav-badge-phonebook" class="px-1.5 py-0.2 text-[10px] rounded-full bg-anthracite-800 text-teal-300 border border-anthracite-700 font-bold">0</span>
</button>
<button type="button" data-tab="ucp" onclick="switchTab('ucp')"
class="tab-btn w-full flex items-center justify-between px-3 py-1.5 rounded-lg text-xs font-medium text-slate-300 hover:bg-anthracite-800 hover:text-white transition">
<span>• Portail Utilisateur (UCP)</span>
<span id="nav-badge-ucp" class="px-1.5 py-0.2 text-[10px] rounded-full bg-anthracite-800 text-sky-300 border border-anthracite-700 font-bold">0</span>
</button>
</div>
</div>
@@ -2199,6 +2204,7 @@ $isDevEnv = file_exists(__DIR__ . '/../.dev')
<select id="manual-ban-jail" class="w-full sm:w-48 px-3 py-2 rounded-lg bg-anthracite-950 border border-anthracite-700 text-xs text-white">
<option value="asterisk">Téléphonie SIP</option>
<option value="nexus-web">Interface Web Admin</option>
<option value="nexus-ucp">Portail Utilisateur (UCP)</option>
<option value="sshd">Console SSH</option>
</select>
<button type="submit"
@@ -2281,6 +2287,55 @@ $isDevEnv = file_exists(__DIR__ . '/../.dev')
</div>
</div>
<!-- Portail Utilisateur Isolé (UCP) & Fail2ban dédié -->
<div class="p-3.5 rounded-xl bg-anthracite-950 border border-anthracite-700 space-y-3">
<div class="flex items-start justify-between gap-3">
<div>
<span class="text-xs font-bold uppercase tracking-wider text-sky-300">Portail Utilisateur Isolé (UCP)</span>
<p class="text-[11px] text-slate-400 leading-tight mt-0.5">VHost Nginx séparé de l'Admin avec prison Fail2ban <code class="text-sky-300">[nexus-ucp]</code>.</p>
</div>
<label class="relative inline-flex items-center cursor-pointer shrink-0 mt-0.5">
<input type="checkbox" name="ucp_enabled" id="ucp_enabled" value="1" class="sr-only peer" checked>
<div class="w-11 h-6 bg-anthracite-700 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-sky-600"></div>
</label>
</div>
<div class="grid grid-cols-2 gap-2.5">
<div>
<label class="block text-[11px] font-medium text-slate-300 mb-1">Port HTTPS UCP</label>
<input type="number" name="ucp_https_port" id="ucp_https_port" value="8444" min="0" max="65535"
class="w-full px-2.5 py-1.5 rounded-lg bg-anthracite-900 border border-anthracite-700 text-xs text-white font-mono">
<p class="text-[10px] text-slate-400 mt-0.5">Défaut : 8444 (0 = désactivé)</p>
</div>
<div>
<label class="block text-[11px] font-medium text-slate-300 mb-1">Port HTTP UCP (Proxy)</label>
<input type="number" name="ucp_http_port" id="ucp_http_port" value="8084" min="0" max="65535"
class="w-full px-2.5 py-1.5 rounded-lg bg-anthracite-900 border border-anthracite-700 text-xs text-white font-mono">
<p class="text-[10px] text-slate-400 mt-0.5">Défaut : 8084 (0 = désactivé)</p>
</div>
</div>
<div class="grid grid-cols-2 gap-2.5 pt-1 border-t border-anthracite-800">
<div>
<label class="block text-[11px] font-medium text-slate-300 mb-1">Max échecs UCP</label>
<select name="fail2ban_ucp_maxretry" class="w-full px-2.5 py-1.5 rounded-lg bg-anthracite-900 border border-anthracite-700 text-xs text-white">
<option value="2">2 tentatives</option>
<option value="3" selected>3 tentatives</option>
<option value="5">5 tentatives</option>
</select>
</div>
<div>
<label class="block text-[11px] font-medium text-slate-300 mb-1">Durée ban UCP</label>
<select name="fail2ban_ucp_bantime" class="w-full px-2.5 py-1.5 rounded-lg bg-anthracite-900 border border-anthracite-700 text-xs text-white">
<option value="3600" selected>1 heure</option>
<option value="43200">12 heures</option>
<option value="86400">24 heures</option>
<option value="604800">7 jours</option>
</select>
</div>
</div>
</div>
<!-- Liste Blanche Permanente (IgnoreIP) -->
<div>
<div class="flex items-center justify-between mb-1">
@@ -2304,6 +2359,80 @@ $isDevEnv = file_exists(__DIR__ . '/../.dev')
</div>
</section>
<!-- ========================================================= -->
<!-- ONGLET UCP : PORTAIL UTILISATEUR & COMPTES -->
<!-- ========================================================= -->
<section id="tab-ucp" class="tab-panel hidden space-y-6">
<!-- En-tête & État d'exposition réseau UCP -->
<div class="rounded-2xl bg-anthracite-900 border border-anthracite-800 p-5 flex flex-col lg:flex-row lg:items-center lg:justify-between gap-4">
<div class="flex items-start gap-4">
<div class="w-11 h-11 rounded-xl bg-sky-500/15 border border-sky-500/30 flex items-center justify-center shrink-0">
<svg class="w-6 h-6 text-sky-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.8">
<path stroke-linecap="round" stroke-linejoin="round" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
</div>
<div>
<div class="flex items-center gap-2.5 flex-wrap">
<h3 class="text-base font-bold text-white">Portail Utilisateur (UCP)</h3>
<span id="ucp-service-status-badge" class="px-2 py-0.5 rounded-full text-[11px] font-semibold bg-emerald-500/15 text-emerald-300 border border-emerald-500/30">
Vérification...
</span>
</div>
<p class="text-xs text-slate-400 mt-1">
VirtualHost Nginx isolé sur <code class="text-sky-300">/var/www/nexus-pbx/ucp</code> — aucun accès à l'administration, protégé par Fail2ban (<code class="text-sky-300">nexus-ucp</code>).
</p>
<div id="ucp-access-urls" class="mt-2.5 flex flex-wrap items-center gap-2 text-xs"></div>
</div>
</div>
<div class="flex items-center gap-2.5 shrink-0">
<button type="button" onclick="switchTab('firewall')"
class="px-3.5 py-2 rounded-xl bg-anthracite-800 hover:bg-anthracite-750 text-slate-200 border border-anthracite-700 text-xs font-semibold transition flex items-center gap-2">
<svg class="w-4 h-4 text-emerald-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
Configurer les Ports & Fail2ban
</button>
<button type="button" onclick="openUcpUserModal()"
class="px-4 py-2 rounded-xl bg-sky-600 hover:bg-sky-500 text-white text-xs font-bold shadow-lg shadow-sky-600/20 transition flex items-center gap-1.5">
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.2">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4v16m8-8H4" />
</svg>
Nouvel Utilisateur UCP
</button>
</div>
</div>
<!-- Tableau des utilisateurs UCP -->
<div class="rounded-2xl bg-anthracite-900 border border-anthracite-800 overflow-hidden">
<div class="px-5 py-4 border-b border-anthracite-800 flex items-center justify-between gap-3">
<div>
<h4 class="text-sm font-bold text-white">Comptes Portail Utilisateur</h4>
<p class="text-[11px] text-slate-400">Chaque compte ne peut gérer que les extensions SIP qui lui sont explicitement assignées.</p>
</div>
</div>
<div class="overflow-x-auto">
<table class="w-full text-left border-collapse">
<thead>
<tr class="border-b border-anthracite-800 bg-anthracite-950/60 text-[11px] font-semibold uppercase tracking-wider text-slate-400">
<th class="px-5 py-3">Identifiant</th>
<th class="px-5 py-3">Nom affiché & Email</th>
<th class="px-5 py-3">Extensions liées</th>
<th class="px-5 py-3">Statut</th>
<th class="px-5 py-3">Dernière connexion</th>
<th class="px-5 py-3 text-right">Actions</th>
</tr>
</thead>
<tbody id="ucp-users-table-body" class="divide-y divide-anthracite-800/70 text-xs">
<tr>
<td colspan="6" class="px-5 py-8 text-center text-slate-500">Chargement des utilisateurs UCP...</td>
</tr>
</tbody>
</table>
</div>
</div>
</section>
<!-- ========================================================= -->
<!-- ONGLET 5 : CODES DE FONCTIONS & SERVICES TÉLÉPHONIQUES -->
<!-- ========================================================= -->
@@ -5458,6 +5587,83 @@ $isDevEnv = file_exists(__DIR__ . '/../.dev')
</div>
</div>
<!-- ========================================================= -->
<!-- MODALE UTILISATEUR UCP (CRÉATION / ÉDITION) -->
<!-- ========================================================= -->
<div id="modal-ucp-user" class="fixed inset-0 z-50 hidden items-center justify-center bg-black/75 backdrop-blur-sm p-4">
<div class="w-full max-w-xl rounded-2xl bg-anthracite-900 border border-anthracite-700 shadow-2xl overflow-hidden">
<div class="px-6 py-4 border-b border-anthracite-800 flex items-center justify-between bg-anthracite-950/60">
<div>
<h3 id="ucp-modal-title" class="text-sm font-bold text-white">Nouvel utilisateur UCP</h3>
<p class="text-[11px] text-slate-400">Attribuez des identifiants et une ou plusieurs extensions SIP</p>
</div>
<button type="button" onclick="closeUcpUserModal()" class="text-slate-400 hover:text-white p-1.5 rounded-lg hover:bg-anthracite-800 transition">✕</button>
</div>
<form id="form-ucp-user" onsubmit="saveUcpUser(event)" class="p-6 space-y-4 max-h-[75vh] overflow-y-auto">
<input type="hidden" id="ucp-user-id" value="0">
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label class="block text-xs font-semibold text-slate-300 mb-1.5">Identifiant de connexion <span class="text-rose-400">*</span></label>
<input type="text" id="ucp-user-username" required autocomplete="off"
placeholder="ex: j.dupont"
class="w-full px-3.5 py-2 rounded-xl bg-anthracite-950 border border-anthracite-700 text-sm text-white focus:border-sky-500 focus:outline-none">
</div>
<div>
<label class="block text-xs font-semibold text-slate-300 mb-1.5">Nom complet affiché</label>
<input type="text" id="ucp-user-display-name" autocomplete="off"
placeholder="ex: Jean Dupont"
class="w-full px-3.5 py-2 rounded-xl bg-anthracite-950 border border-anthracite-700 text-sm text-white focus:border-sky-500 focus:outline-none">
</div>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label class="block text-xs font-semibold text-slate-300 mb-1.5">Adresse Email</label>
<input type="email" id="ucp-user-email" autocomplete="off"
placeholder="jean.dupont@entreprise.fr"
class="w-full px-3.5 py-2 rounded-xl bg-anthracite-950 border border-anthracite-700 text-sm text-white focus:border-sky-500 focus:outline-none">
</div>
<div>
<label class="block text-xs font-semibold text-slate-300 mb-1.5">
Mot de passe <span id="ucp-password-hint" class="text-[11px] font-normal text-slate-400">(min. 6 car.)</span>
</label>
<input type="password" id="ucp-user-password" autocomplete="new-password"
placeholder="••••••••"
class="w-full px-3.5 py-2 rounded-xl bg-anthracite-950 border border-anthracite-700 text-sm text-white focus:border-sky-500 focus:outline-none">
</div>
</div>
<div>
<div class="flex items-center justify-between mb-2">
<label class="text-xs font-semibold text-slate-300">Extensions SIP autorisées</label>
<span class="text-[11px] text-slate-400">Cochez un ou plusieurs postes</span>
</div>
<div id="ucp-user-extensions-container"
class="grid grid-cols-1 sm:grid-cols-2 gap-2 max-h-48 overflow-y-auto p-3 rounded-xl bg-anthracite-950 border border-anthracite-800">
</div>
</div>
<label class="flex items-center gap-2.5 cursor-pointer select-none pt-1">
<input type="checkbox" id="ucp-user-enabled" checked
class="w-4 h-4 rounded border-anthracite-600 bg-anthracite-950 text-sky-500 focus:ring-sky-500">
<span class="text-xs font-medium text-slate-300">Compte actif (autoriser la connexion au portail UCP)</span>
</label>
<div class="pt-3 border-t border-anthracite-800 flex items-center justify-end gap-3">
<button type="button" onclick="closeUcpUserModal()"
class="px-4 py-2 rounded-xl text-xs font-semibold bg-anthracite-800 hover:bg-anthracite-750 text-slate-300 border border-anthracite-700 transition">
Annuler
</button>
<button type="submit" id="btn-save-ucp-user"
class="px-5 py-2 rounded-xl text-xs font-bold bg-sky-600 hover:bg-sky-500 text-white shadow-lg shadow-sky-600/20 transition">
Enregistrer l'utilisateur
</button>
</div>
</form>
</div>
</div>
<script src="/assets/app.js?v=<?= filemtime(__DIR__ . '/assets/app.js') ?>"></script>
</body>
</html>