feat(ucp): NexusPBX v2.5.0 Portail Utilisateur isolé (Refs #4)
This commit is contained in:
1 parent
7922e338e3
commit
72e43f1a9a
18 files changed
+3491
-54
No files matched your search
+22
-10
@@ -184,9 +184,9 @@ touch /var/log/asterisk/messages /var/log/asterisk/security.log /var/log/asteris
|
||||
chown -R asterisk:asterisk /var/log/asterisk 2>/dev/null || true
|
||||
chmod 0664 /var/log/asterisk/messages /var/log/asterisk/security.log /var/log/asterisk/cdr-csv/Master.csv 2>/dev/null || true
|
||||
|
||||
touch /var/log/nexus_security.log
|
||||
chown www-data:asterisk /var/log/nexus_security.log 2>/dev/null || true
|
||||
chmod 0664 /var/log/nexus_security.log 2>/dev/null || true
|
||||
touch /var/log/nexus_security.log /var/log/nexus_ucp_security.log
|
||||
chown www-data:asterisk /var/log/nexus_security.log /var/log/nexus_ucp_security.log 2>/dev/null || true
|
||||
chmod 0664 /var/log/nexus_security.log /var/log/nexus_ucp_security.log 2>/dev/null || true
|
||||
|
||||
# Droits applicatifs stricts
|
||||
chown -R www-data:asterisk "$TARGET_DIR"
|
||||
@@ -195,6 +195,7 @@ chmod 2775 "$TARGET_DIR/storage" \
|
||||
"$TARGET_DIR/storage/system_backups" \
|
||||
"$TARGET_DIR/storage/sounds" \
|
||||
"$TARGET_DIR/public/avatars"
|
||||
[ -d "$TARGET_DIR/ucp" ] && chmod 0755 "$TARGET_DIR/ucp" "$TARGET_DIR/ucp/assets" 2>/dev/null || true
|
||||
|
||||
mkdir -p /var/lib/asterisk/sounds/custom /var/lib/asterisk/moh /var/spool/asterisk/voicemail
|
||||
chown -R asterisk:asterisk /etc/asterisk /var/lib/asterisk /var/spool/asterisk /var/log/asterisk
|
||||
@@ -207,7 +208,7 @@ setfacl -R -d -m u:www-data:rwx,g:asterisk:rwx /etc/asterisk /var/lib/asterisk/s
|
||||
# Droits sudoers pour l'interface web (www-data)
|
||||
cat << 'EOF' > /etc/sudoers.d/nexus-pbx
|
||||
# Privilèges spécifiques NexusPBX pour l'utilisateur Web www-data
|
||||
www-data ALL=(ALL) NOPASSWD: /usr/sbin/asterisk, /usr/sbin/nginx, /usr/bin/systemctl, /usr/bin/fail2ban-client, /bin/fail2ban-client, /var/www/nexus-pbx/scripts/nexus-firewall, /usr/bin/certbot, /usr/sbin/nft, /usr/sbin/iptables, /var/www/nexus-pbx/scripts/update.sh, /usr/local/bin/nexus-update, /var/www/nexus-pbx/scripts/nexus-cluster
|
||||
www-data ALL=(ALL) NOPASSWD: /usr/sbin/asterisk, /usr/sbin/nginx, /usr/bin/systemctl, /usr/bin/fail2ban-client, /bin/fail2ban-client, /var/www/nexus-pbx/scripts/nexus-firewall, /usr/bin/certbot, /usr/sbin/nft, /usr/sbin/iptables, /usr/sbin/ufw, /var/www/nexus-pbx/scripts/update.sh, /usr/local/bin/nexus-update, /var/www/nexus-pbx/scripts/nexus-cluster
|
||||
EOF
|
||||
chmod 0440 /etc/sudoers.d/nexus-pbx
|
||||
|
||||
@@ -436,16 +437,22 @@ mkdir -p /etc/nginx/sites-available /etc/nginx/sites-enabled
|
||||
rm -f /etc/nginx/sites-enabled/default
|
||||
ln -sf /etc/nginx/sites-available/nexus-pbx /etc/nginx/sites-enabled/nexus-pbx
|
||||
|
||||
# Application de la configuration Nginx complète (Ports 80, 8080, 8443)
|
||||
# Application de la configuration Nginx complète (Ports Admin 80/8080/8443 + VHosts UCP isolés 8444/8084)
|
||||
php -r '
|
||||
require_once "/var/www/nexus-pbx/src/Database.php";
|
||||
require_once "/var/www/nexus-pbx/src/FirewallManager.php";
|
||||
$db = App\Database::getConnection();
|
||||
$st = $db->query("SELECT admin_https_port, certbot_strict_port80 FROM settings WHERE id = 1")->fetch(PDO::FETCH_ASSOC);
|
||||
App\FirewallManager::applyNginxConfig((int)($st["admin_https_port"] ?? 8443), (int)($st["certbot_strict_port80"] ?? 0));
|
||||
$st = App\FirewallManager::getSettings();
|
||||
App\FirewallManager::ensureUcpFail2banArtifacts();
|
||||
App\FirewallManager::applyNginxConfig(
|
||||
(int)($st["admin_https_port"] ?? 8443),
|
||||
(int)($st["certbot_strict_port80"] ?? 0),
|
||||
(int)($st["ucp_enabled"] ?? 1),
|
||||
(int)($st["ucp_https_port"] ?? 8444),
|
||||
(int)($st["ucp_http_port"] ?? 8084)
|
||||
);
|
||||
'
|
||||
|
||||
# Filtre Fail2ban pour l'interface Nexus-Web
|
||||
# Filtre Fail2ban pour l'interface Nexus-Web et Portail UCP
|
||||
cat << 'EOF' > /etc/fail2ban/filter.d/nexus-web.conf
|
||||
[Definition]
|
||||
failregex = ^.*\[NEXUS-WEB-AUTH-FAIL\]:\s*<HOST>.*$
|
||||
@@ -465,7 +472,12 @@ App\FirewallManager::generateJailLocal(
|
||||
(int)$st["fail2ban_sip_bantime"],
|
||||
(int)$st["fail2ban_web_maxretry"],
|
||||
(int)$st["fail2ban_web_bantime"],
|
||||
(int)$st["admin_https_port"]
|
||||
(int)$st["admin_https_port"],
|
||||
(int)($st["ucp_enabled"] ?? 1),
|
||||
(int)($st["ucp_https_port"] ?? 8444),
|
||||
(int)($st["ucp_http_port"] ?? 8084),
|
||||
(int)($st["fail2ban_ucp_maxretry"] ?? 3),
|
||||
(int)($st["fail2ban_ucp_bantime"] ?? 3600)
|
||||
);
|
||||
'
|
||||
|
||||
|
||||
+37
-4
@@ -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
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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>
|
||||
@@ -19,6 +19,8 @@ switch ($cmd) {
|
||||
echo "• Pare-feu & Filtrage : " . ($settings['firewall_enabled'] ? "\033[32mACTIF\033[0m" : "\033[33mINACTIF\033[0m") . "\n";
|
||||
echo "• Mode Strict Certbot 80 : " . ($settings['certbot_strict_port80'] ? "OUI (Port 80 ACME seul)" : "NON (Port 80 ouvert)") . "\n";
|
||||
echo "• Port HTTPS Admin : " . ($settings['admin_https_port'] ?? 8443) . "\n";
|
||||
$ucpSt = !empty($settings['ucp_enabled']) ? "\033[32mACTIF\033[0m (HTTPS:" . ($settings['ucp_https_port'] ?? 8444) . " / HTTP:" . ($settings['ucp_http_port'] ?? 8084) . ")" : "\033[33mINACTIF\033[0m";
|
||||
echo "• Portail Utilisateur UCP: " . $ucpSt . "\n";
|
||||
echo "• Total IPs Bannies : " . $st['total_banned'] . "\n";
|
||||
if (!empty($st['banned_ips'])) {
|
||||
echo "\nListe des IPs bannies :\n";
|
||||
|
||||
+39
-4
@@ -151,7 +151,7 @@ done
|
||||
# Mise à jour des privilèges sudoers (www-data) si accessible en écriture
|
||||
if [ -w /etc/sudoers.d ]; then
|
||||
cat << 'EOF_SUDO' > /etc/sudoers.d/nexus-pbx 2>/dev/null || true
|
||||
www-data ALL=(ALL) NOPASSWD: /usr/sbin/asterisk, /usr/sbin/nginx, /usr/bin/systemctl, /usr/bin/fail2ban-client, /bin/fail2ban-client, /var/www/nexus-pbx/scripts/nexus-firewall, /usr/bin/certbot, /usr/sbin/nft, /usr/sbin/iptables, /var/www/nexus-pbx/scripts/update.sh, /usr/local/bin/nexus-update, /var/www/nexus-pbx/scripts/nexus-cluster
|
||||
www-data ALL=(ALL) NOPASSWD: /usr/sbin/asterisk, /usr/sbin/nginx, /usr/bin/systemctl, /usr/bin/fail2ban-client, /bin/fail2ban-client, /var/www/nexus-pbx/scripts/nexus-firewall, /usr/bin/certbot, /usr/sbin/nft, /usr/sbin/iptables, /usr/sbin/ufw, /var/www/nexus-pbx/scripts/update.sh, /usr/local/bin/nexus-update, /var/www/nexus-pbx/scripts/nexus-cluster
|
||||
EOF_SUDO
|
||||
chmod 0440 /etc/sudoers.d/nexus-pbx 2>/dev/null || true
|
||||
fi
|
||||
@@ -163,14 +163,48 @@ which ffmpeg >/dev/null 2>&1 && which rsync >/dev/null 2>&1 && which sox >/dev/n
|
||||
(apt-get update -y -qq && apt-get install -y -qq --no-install-recommends acl ffmpeg rsync sox sshpass keepalived iputils-arping) 2>/dev/null || true
|
||||
}
|
||||
|
||||
# 4. Exécution des migrations SQLite et régénération Dialplan
|
||||
echo -e "${C_GRAY}[4/6] Exécution des migrations de base de données & régénération dialplan...${C_RESET}"
|
||||
# 4. Exécution des migrations SQLite, régénération Dialplan, Nginx & Fail2ban UCP
|
||||
echo -e "${C_GRAY}[4/6] Exécution des migrations de base de données & régénération dialplan/web...${C_RESET}"
|
||||
if [ -w /var/log ]; then
|
||||
touch /var/log/nexus_ucp_security.log 2>/dev/null || true
|
||||
chown www-data:asterisk /var/log/nexus_ucp_security.log 2>/dev/null || true
|
||||
chmod 0664 /var/log/nexus_ucp_security.log 2>/dev/null || true
|
||||
fi
|
||||
[ -d "$APP_DIR/ucp" ] && chmod 0755 "$APP_DIR/ucp" "$APP_DIR/ucp/assets" 2>/dev/null || true
|
||||
php -r '
|
||||
require_once "/var/www/nexus-pbx/src/Database.php";
|
||||
require_once "/var/www/nexus-pbx/src/ConfigGenerator.php";
|
||||
require_once "/var/www/nexus-pbx/src/FirewallManager.php";
|
||||
$db = App\Database::getConnection(true);
|
||||
$gen = new App\ConfigGenerator($db);
|
||||
$gen->writeAllConfigs();
|
||||
try {
|
||||
$st = App\FirewallManager::getSettings();
|
||||
App\FirewallManager::ensureUcpFail2banArtifacts();
|
||||
App\FirewallManager::applyNginxConfig(
|
||||
(int)($st["admin_https_port"] ?? 8443),
|
||||
(int)($st["certbot_strict_port80"] ?? 0),
|
||||
(int)($st["ucp_enabled"] ?? 1),
|
||||
(int)($st["ucp_https_port"] ?? 8444),
|
||||
(int)($st["ucp_http_port"] ?? 8084)
|
||||
);
|
||||
App\FirewallManager::generateJailLocal(
|
||||
(string)($st["fail2ban_whitelist_ips"] ?? ""),
|
||||
(int)($st["fail2ban_sip_maxretry"] ?? 3),
|
||||
(int)($st["fail2ban_sip_findtime"] ?? 600),
|
||||
(int)($st["fail2ban_sip_bantime"] ?? 3600),
|
||||
(int)($st["fail2ban_web_maxretry"] ?? 3),
|
||||
(int)($st["fail2ban_web_bantime"] ?? 3600),
|
||||
(int)($st["admin_https_port"] ?? 8443),
|
||||
(int)($st["ucp_enabled"] ?? 1),
|
||||
(int)($st["ucp_https_port"] ?? 8444),
|
||||
(int)($st["ucp_http_port"] ?? 8084),
|
||||
(int)($st["fail2ban_ucp_maxretry"] ?? 3),
|
||||
(int)($st["fail2ban_ucp_bantime"] ?? 3600)
|
||||
);
|
||||
} catch (\Throwable $e) {
|
||||
fwrite(STDERR, "Warning UCP/Firewall update: " . $e->getMessage() . "\n");
|
||||
}
|
||||
'
|
||||
|
||||
# 5. Fix permissions & ACLs
|
||||
@@ -211,8 +245,9 @@ asterisk -rx "dialplan reload" >/dev/null 2>&1 || true
|
||||
if [ -t 1 ]; then
|
||||
systemctl reload php8.4-fpm 2>/dev/null || systemctl reload php8.2-fpm 2>/dev/null || systemctl reload php*-fpm 2>/dev/null || true
|
||||
systemctl reload nginx 2>/dev/null || true
|
||||
fail2ban-client reload >/dev/null 2>&1 || true
|
||||
else
|
||||
(nohup bash -c "sleep 2 && (systemctl reload php8.4-fpm 2>/dev/null || systemctl reload php8.2-fpm 2>/dev/null || systemctl reload php*-fpm 2>/dev/null || true); systemctl reload nginx 2>/dev/null || true" >/dev/null 2>&1 &)
|
||||
(nohup bash -c "sleep 2 && (systemctl reload php8.4-fpm 2>/dev/null || systemctl reload php8.2-fpm 2>/dev/null || systemctl reload php*-fpm 2>/dev/null || true); systemctl reload nginx 2>/dev/null || true; fail2ban-client reload >/dev/null 2>&1 || true" >/dev/null 2>&1 &)
|
||||
fi
|
||||
|
||||
NEW_VER="Inconnue"
|
||||
|
||||
+44
-1
@@ -548,6 +548,9 @@ class ClusterManager
|
||||
if (is_dir('/var/spool/asterisk/voicemail')) {
|
||||
$cmd .= " -C /var/spool/asterisk voicemail";
|
||||
}
|
||||
if (is_dir('/var/www/nexus-pbx/public/avatars')) {
|
||||
$cmd .= " -C /var/www/nexus-pbx/public avatars";
|
||||
}
|
||||
if ($hasAstdb) {
|
||||
$cmd .= " -C /tmp astdb_cluster_sync.sqlite3";
|
||||
}
|
||||
@@ -747,6 +750,17 @@ class ClusterManager
|
||||
shell_exec("chown -R asterisk:asterisk /var/spool/asterisk/voicemail 2>/dev/null || true");
|
||||
}
|
||||
|
||||
// Synchronisation miroir des avatars de l'annuaire / UCP
|
||||
$extractedAvatars = $tmpExtractDir . '/avatars';
|
||||
if (is_dir($extractedAvatars)) {
|
||||
if (!is_dir('/var/www/nexus-pbx/public/avatars')) {
|
||||
@mkdir('/var/www/nexus-pbx/public/avatars', 0775, true);
|
||||
}
|
||||
shell_exec("rsync -a --delete " . escapeshellarg($extractedAvatars . '/') . " /var/www/nexus-pbx/public/avatars/ 2>/dev/null || true");
|
||||
shell_exec("chown -R www-data:asterisk /var/www/nexus-pbx/public/avatars 2>/dev/null || true");
|
||||
shell_exec("chmod -R 775 /var/www/nexus-pbx/public/avatars 2>/dev/null || true");
|
||||
}
|
||||
|
||||
// Restauration de l'astdb (contacts et IP actives des téléphones PJSIP) via le helper root
|
||||
$extractedAstdb = $tmpExtractDir . '/astdb_cluster_sync.sqlite3';
|
||||
if (file_exists($extractedAstdb)) {
|
||||
@@ -754,7 +768,7 @@ class ClusterManager
|
||||
@unlink($extractedAstdb);
|
||||
}
|
||||
|
||||
@rmdir($tmpExtractDir);
|
||||
shell_exec('rm -rf ' . escapeshellarg($tmpExtractDir) . ' 2>/dev/null || true');
|
||||
@unlink($tmpArchive);
|
||||
|
||||
// Réouverture de la connexion DB fraîche
|
||||
@@ -809,6 +823,35 @@ class ClusterManager
|
||||
// Régénérer les configurations PJSIP et Asterisk
|
||||
ConfigGenerator::generateAndApply($newDb);
|
||||
|
||||
// Synchroniser la configuration Nginx/Fail2ban locale (dont ports et jail UCP répliqués)
|
||||
try {
|
||||
$fw = FirewallManager::getSettings();
|
||||
FirewallManager::ensureUcpFail2banArtifacts();
|
||||
FirewallManager::applyNginxConfig(
|
||||
(int)($fw['admin_https_port'] ?? 8443),
|
||||
(int)($fw['certbot_strict_port80'] ?? 0),
|
||||
(int)($fw['ucp_enabled'] ?? 1),
|
||||
(int)($fw['ucp_https_port'] ?? 8444),
|
||||
(int)($fw['ucp_http_port'] ?? 8084)
|
||||
);
|
||||
FirewallManager::generateJailLocal(
|
||||
(string)($fw['fail2ban_whitelist_ips'] ?? ''),
|
||||
(int)($fw['fail2ban_sip_maxretry'] ?? 3),
|
||||
(int)($fw['fail2ban_sip_findtime'] ?? 600),
|
||||
(int)($fw['fail2ban_sip_bantime'] ?? 3600),
|
||||
(int)($fw['fail2ban_web_maxretry'] ?? 3),
|
||||
(int)($fw['fail2ban_web_bantime'] ?? 3600),
|
||||
(int)($fw['admin_https_port'] ?? 8443),
|
||||
(int)($fw['ucp_enabled'] ?? 1),
|
||||
(int)($fw['ucp_https_port'] ?? 8444),
|
||||
(int)($fw['ucp_http_port'] ?? 8084),
|
||||
(int)($fw['fail2ban_ucp_maxretry'] ?? 3),
|
||||
(int)($fw['fail2ban_ucp_bantime'] ?? 3600)
|
||||
);
|
||||
} catch (\Throwable $e) {
|
||||
// Non-bloquant lors de la synchronisation HA
|
||||
}
|
||||
|
||||
// Rechargement à chaud d'Asterisk pour appliquer immédiatement la configuration en RAM
|
||||
try {
|
||||
$ami = AmiClient::fromDatabase($newDb);
|
||||
|
||||
@@ -987,9 +987,9 @@ class ConfigGenerator
|
||||
$lines[] = " same => n,Hangup()";
|
||||
}
|
||||
$lines[] = " same => n(cf_{$num}),NoOp(Poste {$num} renvoi inconditionnel vers \${DB(CF/{$num})})";
|
||||
$lines[] = " same => n,Goto(from-internal,\${DB(CF/{$num})},1)";
|
||||
$lines[] = " same => n,GotoIf(\$[\"\${DB(CF/{$num}):0:3}\" = \"vm-\"]?app-voicemail,\${DB(CF/{$num}):3},1:from-internal,\${DB(CF/{$num})},1)";
|
||||
$lines[] = " same => n(cfu_{$num}),NoOp(Poste {$num} indisponible, renvoi vers \${DB(CFU/{$num})})";
|
||||
$lines[] = " same => n,Goto(from-internal,\${DB(CFU/{$num})},1)";
|
||||
$lines[] = " same => n,GotoIf(\$[\"\${DB(CFU/{$num}):0:3}\" = \"vm-\"]?app-voicemail,\${DB(CFU/{$num}):3},1:from-internal,\${DB(CFU/{$num})},1)";
|
||||
$lines[] = "";
|
||||
}
|
||||
|
||||
|
||||
+22
-1
@@ -48,7 +48,8 @@ class Database
|
||||
// Si la base existe déjà, éviter d'exécuter l'intégralité du DDL à chaque requête HTTP
|
||||
// ce qui sature SQLite de verrous exclusifs inutiles. On s'assure des colonnes requises.
|
||||
try {
|
||||
self::$instance->query("SELECT rtp_timeout_hold FROM settings LIMIT 1");
|
||||
self::$instance->query("SELECT ucp_https_port, fail2ban_ucp_maxretry FROM settings LIMIT 1");
|
||||
self::$instance->query("SELECT id FROM ucp_users LIMIT 1");
|
||||
} catch (\Throwable $e) {
|
||||
self::initializeSchema();
|
||||
}
|
||||
@@ -338,6 +339,21 @@ class Database
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- 15. Table ucp_users : Utilisateurs du portail UCP (isolé de l'Admin)
|
||||
CREATE TABLE IF NOT EXISTS ucp_users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
username VARCHAR(64) NOT NULL UNIQUE,
|
||||
display_name VARCHAR(128) NOT NULL DEFAULT '',
|
||||
email VARCHAR(128) NOT NULL DEFAULT '',
|
||||
password_hash TEXT NOT NULL,
|
||||
extensions_json TEXT NOT NULL DEFAULT '[]',
|
||||
enabled INTEGER NOT NULL DEFAULT 1 CHECK (enabled IN (0, 1)),
|
||||
last_login_at DATETIME DEFAULT NULL,
|
||||
last_login_ip VARCHAR(64) NOT NULL DEFAULT '',
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
SQL;
|
||||
|
||||
$db->exec($schema);
|
||||
@@ -418,6 +434,11 @@ class Database
|
||||
'fail2ban_web_maxretry' => "INTEGER NOT NULL DEFAULT 3",
|
||||
'fail2ban_web_bantime' => "INTEGER NOT NULL DEFAULT 3600",
|
||||
'fail2ban_whitelist_ips' => "TEXT NOT NULL DEFAULT '127.0.0.1/8 10.0.0.0/8 172.16.0.0/12 192.168.0.0/16 82.122.84.60'",
|
||||
'ucp_enabled' => "INTEGER NOT NULL DEFAULT 1",
|
||||
'ucp_https_port' => "INTEGER NOT NULL DEFAULT 8444",
|
||||
'ucp_http_port' => "INTEGER NOT NULL DEFAULT 8084",
|
||||
'fail2ban_ucp_maxretry' => "INTEGER NOT NULL DEFAULT 3",
|
||||
'fail2ban_ucp_bantime' => "INTEGER NOT NULL DEFAULT 3600",
|
||||
];
|
||||
foreach ($extraSettings as $colName => $colDef) {
|
||||
if (!in_array($colName, $existing, true)) {
|
||||
|
||||
+286
-18
@@ -13,6 +13,9 @@ class FirewallManager
|
||||
public const JAIL_LOCAL_PATH = '/etc/fail2ban/jail.local';
|
||||
public const NGINX_CONF_PATH = '/etc/nginx/sites-available/nexus-pbx';
|
||||
public const WEB_FILTER_PATH = '/etc/fail2ban/filter.d/nexus-web.conf';
|
||||
public const UCP_FILTER_PATH = '/etc/fail2ban/filter.d/nexus-ucp.conf';
|
||||
public const UCP_SECURITY_LOG = '/var/log/nexus_ucp_security.log';
|
||||
public const UCP_ROOT_DIR = '/var/www/nexus-pbx/ucp';
|
||||
|
||||
/**
|
||||
* Récupère la configuration actuelle du pare-feu et de Fail2ban depuis SQLite.
|
||||
@@ -23,7 +26,9 @@ class FirewallManager
|
||||
$stmt = $db->query("SELECT
|
||||
firewall_enabled, admin_https_port, certbot_strict_port80,
|
||||
fail2ban_sip_maxretry, fail2ban_sip_findtime, fail2ban_sip_bantime,
|
||||
fail2ban_web_maxretry, fail2ban_web_bantime, fail2ban_whitelist_ips
|
||||
fail2ban_web_maxretry, fail2ban_web_bantime, fail2ban_whitelist_ips,
|
||||
ucp_enabled, ucp_https_port, ucp_http_port,
|
||||
fail2ban_ucp_maxretry, fail2ban_ucp_bantime
|
||||
FROM settings WHERE id = 1");
|
||||
$row = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
@@ -38,6 +43,11 @@ class FirewallManager
|
||||
'fail2ban_web_maxretry' => 3,
|
||||
'fail2ban_web_bantime' => 3600,
|
||||
'fail2ban_whitelist_ips' => '127.0.0.1/8 10.0.0.0/8 172.16.0.0/12 192.168.0.0/16 82.122.84.60',
|
||||
'ucp_enabled' => 1,
|
||||
'ucp_https_port' => 8444,
|
||||
'ucp_http_port' => 8084,
|
||||
'fail2ban_ucp_maxretry' => 3,
|
||||
'fail2ban_ucp_bantime' => 3600,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -51,6 +61,11 @@ class FirewallManager
|
||||
'fail2ban_web_maxretry' => (int)($row['fail2ban_web_maxretry'] ?? 3),
|
||||
'fail2ban_web_bantime' => (int)($row['fail2ban_web_bantime'] ?? 3600),
|
||||
'fail2ban_whitelist_ips' => (string)($row['fail2ban_whitelist_ips'] ?? '127.0.0.1/8 10.0.0.0/8 172.16.0.0/12 192.168.0.0/16 82.122.84.60'),
|
||||
'ucp_enabled' => (int)($row['ucp_enabled'] ?? 1),
|
||||
'ucp_https_port' => (int)($row['ucp_https_port'] ?? 8444),
|
||||
'ucp_http_port' => (int)($row['ucp_http_port'] ?? 8084),
|
||||
'fail2ban_ucp_maxretry' => (int)($row['fail2ban_ucp_maxretry'] ?? 3),
|
||||
'fail2ban_ucp_bantime' => (int)($row['fail2ban_ucp_bantime'] ?? 3600),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -111,11 +126,8 @@ class FirewallManager
|
||||
}
|
||||
}
|
||||
|
||||
// IP actuelle du client
|
||||
$currentIp = $_SERVER['HTTP_X_FORWARDED_FOR'] ?? $_SERVER['REMOTE_ADDR'] ?? '127.0.0.1';
|
||||
if (str_contains($currentIp, ',')) {
|
||||
$currentIp = trim(explode(',', $currentIp)[0]);
|
||||
}
|
||||
// IP actuelle du client (avec résolution reverse proxy si proxy privé/local)
|
||||
$currentIp = self::resolveClientIp();
|
||||
|
||||
$status = [
|
||||
'is_running' => $isRunning,
|
||||
@@ -151,7 +163,7 @@ class FirewallManager
|
||||
}
|
||||
} else {
|
||||
// Débannir de toutes les prisons connues
|
||||
foreach (['asterisk', 'nexus-web', 'sshd'] as $j) {
|
||||
foreach (['asterisk', 'nexus-web', 'nexus-ucp', 'sshd'] as $j) {
|
||||
$cmd = "timeout 2 sudo -n /usr/bin/fail2ban-client set " . escapeshellarg($j) . " unbanip " . escapeshellarg($ip) . " 2>&1";
|
||||
exec($cmd, $out, $code);
|
||||
if ($code === 0) {
|
||||
@@ -200,6 +212,7 @@ class FirewallManager
|
||||
{
|
||||
$db = Database::getConnection();
|
||||
|
||||
$oldSettings = self::getSettings();
|
||||
$strictPort80 = !empty($data['certbot_strict_port80']) ? 1 : 0;
|
||||
$firewallEnabled = isset($data['firewall_enabled']) ? (!empty($data['firewall_enabled']) ? 1 : 0) : 1;
|
||||
$adminPort = max(1, min(65535, (int)($data['admin_https_port'] ?? 8443)));
|
||||
@@ -209,6 +222,26 @@ class FirewallManager
|
||||
$webMaxRetry = max(1, min(20, (int)($data['fail2ban_web_maxretry'] ?? 3)));
|
||||
$webBanTime = max(60, min(31536000, (int)($data['fail2ban_web_bantime'] ?? 3600)));
|
||||
|
||||
$ucpEnabled = isset($data['ucp_enabled']) ? (!empty($data['ucp_enabled']) ? 1 : 0) : (int)$oldSettings['ucp_enabled'];
|
||||
$ucpHttpsPort = isset($data['ucp_https_port']) ? max(0, min(65535, (int)$data['ucp_https_port'])) : (int)$oldSettings['ucp_https_port'];
|
||||
$ucpHttpPort = isset($data['ucp_http_port']) ? max(0, min(65535, (int)$data['ucp_http_port'])) : (int)$oldSettings['ucp_http_port'];
|
||||
$ucpMaxRetry = isset($data['fail2ban_ucp_maxretry']) ? max(1, min(20, (int)$data['fail2ban_ucp_maxretry'])) : (int)$oldSettings['fail2ban_ucp_maxretry'];
|
||||
$ucpBanTime = isset($data['fail2ban_ucp_bantime']) ? max(60, min(31536000, (int)$data['fail2ban_ucp_bantime'])) : (int)$oldSettings['fail2ban_ucp_bantime'];
|
||||
|
||||
if ($ucpEnabled === 1 && $ucpHttpsPort === 0 && $ucpHttpPort === 0) {
|
||||
throw new InvalidArgumentException("Lorsque l'UCP est activé, au moins un port (HTTPS ou HTTP) doit être supérieur à 0.");
|
||||
}
|
||||
$reservedPorts = [80, 443, 8080, $adminPort, 5060, 5061];
|
||||
if ($ucpHttpsPort > 0 && in_array($ucpHttpsPort, $reservedPorts, true)) {
|
||||
throw new InvalidArgumentException("Le port HTTPS UCP ({$ucpHttpsPort}) entre en conflit avec un port réservé (Admin/SIP/ACME).");
|
||||
}
|
||||
if ($ucpHttpPort > 0 && in_array($ucpHttpPort, $reservedPorts, true)) {
|
||||
throw new InvalidArgumentException("Le port HTTP UCP ({$ucpHttpPort}) entre en conflit avec un port réservé (Admin/SIP/ACME).");
|
||||
}
|
||||
if ($ucpHttpsPort > 0 && $ucpHttpPort > 0 && $ucpHttpsPort === $ucpHttpPort) {
|
||||
throw new InvalidArgumentException("Les ports HTTPS et HTTP de l'UCP doivent être différents.");
|
||||
}
|
||||
|
||||
$whitelist = trim((string)($data['fail2ban_whitelist_ips'] ?? ''));
|
||||
$cleanTokens = array_values(array_filter(preg_split('/\s+/', $whitelist)));
|
||||
|
||||
@@ -233,6 +266,11 @@ class FirewallManager
|
||||
fail2ban_web_maxretry = :wmr,
|
||||
fail2ban_web_bantime = :wbt,
|
||||
fail2ban_whitelist_ips = :wl,
|
||||
ucp_enabled = :ue,
|
||||
ucp_https_port = :uhs,
|
||||
ucp_http_port = :uhp,
|
||||
fail2ban_ucp_maxretry = :umr,
|
||||
fail2ban_ucp_bantime = :ubt,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = 1");
|
||||
|
||||
@@ -246,9 +284,15 @@ class FirewallManager
|
||||
':wmr' => $webMaxRetry,
|
||||
':wbt' => $webBanTime,
|
||||
':wl' => $finalWhitelist,
|
||||
':ue' => $ucpEnabled,
|
||||
':uhs' => $ucpHttpsPort,
|
||||
':uhp' => $ucpHttpPort,
|
||||
':umr' => $ucpMaxRetry,
|
||||
':ubt' => $ucpBanTime,
|
||||
]);
|
||||
|
||||
// 2. Génération et application du fichier /etc/fail2ban/jail.local
|
||||
// 2. Garantir la présence du filtre & journal Fail2ban UCP puis générer /etc/fail2ban/jail.local
|
||||
self::ensureUcpFail2banArtifacts();
|
||||
self::generateJailLocal(
|
||||
$finalWhitelist,
|
||||
$sipMaxRetry,
|
||||
@@ -256,7 +300,12 @@ class FirewallManager
|
||||
$sipBanTime,
|
||||
$webMaxRetry,
|
||||
$webBanTime,
|
||||
$adminPort
|
||||
$adminPort,
|
||||
$ucpEnabled,
|
||||
$ucpHttpsPort,
|
||||
$ucpHttpPort,
|
||||
$ucpMaxRetry,
|
||||
$ucpBanTime
|
||||
);
|
||||
|
||||
// 3. Rechargement / Redémarrage propre de Fail2ban
|
||||
@@ -265,14 +314,19 @@ class FirewallManager
|
||||
exec('timeout 5 sudo -n /usr/bin/systemctl restart fail2ban 2>&1');
|
||||
}
|
||||
|
||||
// 4. Mise à jour de la configuration Nginx
|
||||
self::applyNginxConfig($adminPort, $strictPort80);
|
||||
// 4. Mise à jour de la configuration Nginx & ouverture/fermeture dynamique des ports UCP
|
||||
self::applyNginxConfig($adminPort, $strictPort80, $ucpEnabled, $ucpHttpsPort, $ucpHttpPort);
|
||||
self::syncUcpFirewallPorts($oldSettings, [
|
||||
'ucp_enabled' => $ucpEnabled,
|
||||
'ucp_https_port' => $ucpHttpsPort,
|
||||
'ucp_http_port' => $ucpHttpPort,
|
||||
]);
|
||||
|
||||
@unlink('/tmp/nexus_fw_status_cache.json');
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'message' => 'Configuration Pare-feu & Fail2ban appliquée avec succès ! Nginx et Fail2ban rechargés.',
|
||||
'message' => 'Configuration Pare-feu, UCP & Fail2ban appliquée avec succès ! Nginx et Fail2ban rechargés.',
|
||||
'settings' => self::getSettings(),
|
||||
];
|
||||
}
|
||||
@@ -287,9 +341,30 @@ class FirewallManager
|
||||
int $sipBanTime,
|
||||
int $webMaxRetry,
|
||||
int $webBanTime,
|
||||
int $adminPort
|
||||
int $adminPort,
|
||||
?int $ucpEnabled = null,
|
||||
?int $ucpHttpsPort = null,
|
||||
?int $ucpHttpPort = null,
|
||||
?int $ucpMaxRetry = null,
|
||||
?int $ucpBanTime = null
|
||||
): void {
|
||||
if ($ucpEnabled === null || $ucpHttpsPort === null || $ucpHttpPort === null || $ucpMaxRetry === null || $ucpBanTime === null) {
|
||||
$st = self::getSettings();
|
||||
$ucpEnabled = $ucpEnabled ?? $st['ucp_enabled'];
|
||||
$ucpHttpsPort = $ucpHttpsPort ?? $st['ucp_https_port'];
|
||||
$ucpHttpPort = $ucpHttpPort ?? $st['ucp_http_port'];
|
||||
$ucpMaxRetry = $ucpMaxRetry ?? $st['fail2ban_ucp_maxretry'];
|
||||
$ucpBanTime = $ucpBanTime ?? $st['fail2ban_ucp_bantime'];
|
||||
}
|
||||
|
||||
self::ensureUcpFail2banArtifacts();
|
||||
|
||||
$portsWeb = "http,https,{$adminPort}";
|
||||
$ucpPortsList = [];
|
||||
if ($ucpHttpsPort > 0) $ucpPortsList[] = (string)$ucpHttpsPort;
|
||||
if ($ucpHttpPort > 0) $ucpPortsList[] = (string)$ucpHttpPort;
|
||||
$ucpJailEnabled = ($ucpEnabled === 1 && !empty($ucpPortsList)) ? 'true' : 'false';
|
||||
$portsUcp = !empty($ucpPortsList) ? implode(',', $ucpPortsList) : '8444';
|
||||
|
||||
$conf = <<<EOF
|
||||
# Fichier généré automatiquement par NexusPBX
|
||||
@@ -331,6 +406,16 @@ bantime = {$webBanTime}
|
||||
port = {$portsWeb}
|
||||
protocol = tcp
|
||||
|
||||
[nexus-ucp]
|
||||
enabled = {$ucpJailEnabled}
|
||||
filter = nexus-ucp
|
||||
logpath = /var/log/nexus_ucp_security.log
|
||||
maxretry = {$ucpMaxRetry}
|
||||
findtime = 600
|
||||
bantime = {$ucpBanTime}
|
||||
port = {$portsUcp}
|
||||
protocol = tcp
|
||||
|
||||
EOF;
|
||||
|
||||
file_put_contents(self::JAIL_LOCAL_PATH, $conf);
|
||||
@@ -339,10 +424,22 @@ EOF;
|
||||
}
|
||||
|
||||
/**
|
||||
* Met à jour Nginx pour séparer le port 80 (Certbot/ACME uniquement si strict) et écouter sur le port d'admin.
|
||||
* Met à jour Nginx pour séparer le port 80 (Certbot/ACME), le port d'admin et les VHosts isolés de l'UCP.
|
||||
*/
|
||||
public static function applyNginxConfig(int $adminHttpsPort, int $strictPort80): void
|
||||
{
|
||||
public static function applyNginxConfig(
|
||||
int $adminHttpsPort,
|
||||
int $strictPort80,
|
||||
?int $ucpEnabled = null,
|
||||
?int $ucpHttpsPort = null,
|
||||
?int $ucpHttpPort = null
|
||||
): void {
|
||||
if ($ucpEnabled === null || $ucpHttpsPort === null || $ucpHttpPort === null) {
|
||||
$st = self::getSettings();
|
||||
$ucpEnabled = $ucpEnabled ?? $st['ucp_enabled'];
|
||||
$ucpHttpsPort = $ucpHttpsPort ?? $st['ucp_https_port'];
|
||||
$ucpHttpPort = $ucpHttpPort ?? $st['ucp_http_port'];
|
||||
}
|
||||
|
||||
$phpSock = '/run/php/php8.4-fpm.sock';
|
||||
if (!file_exists($phpSock)) {
|
||||
$found = glob('/run/php/php*-fpm.sock');
|
||||
@@ -485,7 +582,19 @@ server {
|
||||
}
|
||||
EOF;
|
||||
|
||||
$fullNginxConfig = $port80Block . "\n\n" . $phonebookBlock . "\n\n" . $httpsBlock . "\n";
|
||||
$blocks = [$port80Block, $phonebookBlock, $httpsBlock];
|
||||
|
||||
// VirtualHosts Nginx totalement isolés pour l'UCP (/var/www/nexus-pbx/ucp)
|
||||
if ($ucpEnabled === 1 && is_dir(self::UCP_ROOT_DIR)) {
|
||||
if ($ucpHttpsPort > 0) {
|
||||
$blocks[] = self::buildUcpServerBlock($ucpHttpsPort, true, $phpSock);
|
||||
}
|
||||
if ($ucpHttpPort > 0) {
|
||||
$blocks[] = self::buildUcpServerBlock($ucpHttpPort, false, $phpSock);
|
||||
}
|
||||
}
|
||||
|
||||
$fullNginxConfig = implode("\n\n", $blocks) . "\n";
|
||||
file_put_contents(self::NGINX_CONF_PATH, $fullNginxConfig);
|
||||
@chgrp(self::NGINX_CONF_PATH, 'asterisk');
|
||||
@chmod(self::NGINX_CONF_PATH, 0664);
|
||||
@@ -500,6 +609,159 @@ EOF;
|
||||
exec('sudo -n /usr/bin/systemctl reload nginx 2>&1');
|
||||
}
|
||||
|
||||
private static function buildUcpServerBlock(int $port, bool $ssl, string $phpSock): string
|
||||
{
|
||||
$ucpRoot = self::UCP_ROOT_DIR;
|
||||
$listenDirectives = $ssl
|
||||
? "listen {$port} ssl;\n listen [::]:{$port} ssl;"
|
||||
: "listen {$port};\n listen [::]:{$port};";
|
||||
|
||||
$sslDirectives = $ssl ? <<<SSL
|
||||
|
||||
ssl_certificate /etc/asterisk/keys/active_cert.crt;
|
||||
ssl_certificate_key /etc/asterisk/keys/active_key.key;
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
ssl_ciphers HIGH:!aNULL:!MD5;
|
||||
ssl_prefer_server_ciphers on;
|
||||
SSL : '';
|
||||
|
||||
$logPrefix = $ssl ? 'nexus-ucp.ssl' : 'nexus-ucp';
|
||||
|
||||
return <<<EOF
|
||||
server {
|
||||
{$listenDirectives}
|
||||
server_name _;{$sslDirectives}
|
||||
|
||||
client_max_body_size 16M;
|
||||
|
||||
root {$ucpRoot};
|
||||
index index.php;
|
||||
|
||||
access_log /var/log/nginx/{$logPrefix}.access.log;
|
||||
error_log /var/log/nginx/{$logPrefix}.error.log;
|
||||
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
|
||||
location / {
|
||||
try_files \$uri \$uri/ /index.php?\$query_string;
|
||||
}
|
||||
|
||||
location ~* \.(?:css|js)$ {
|
||||
expires -1;
|
||||
add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0";
|
||||
try_files \$uri =404;
|
||||
}
|
||||
|
||||
location ~ \.php$ {
|
||||
include snippets/fastcgi-php.conf;
|
||||
fastcgi_pass unix:{$phpSock};
|
||||
fastcgi_param SCRIPT_FILENAME \$document_root\$fastcgi_script_name;
|
||||
}
|
||||
|
||||
location ~ /\. {
|
||||
deny all;
|
||||
}
|
||||
}
|
||||
EOF;
|
||||
}
|
||||
|
||||
/**
|
||||
* Crée le filtre et le fichier de log Fail2ban dédiés au portail UCP.
|
||||
*/
|
||||
public static function ensureUcpFail2banArtifacts(): void
|
||||
{
|
||||
$filterContent = "[Definition]\nfailregex = ^.*\\[NEXUS-UCP-AUTH-FAIL\\]:\\s*<HOST>.*$\nignoreregex =\n";
|
||||
if (!file_exists(self::UCP_FILTER_PATH) || file_get_contents(self::UCP_FILTER_PATH) !== $filterContent) {
|
||||
@file_put_contents(self::UCP_FILTER_PATH, $filterContent);
|
||||
@chmod(self::UCP_FILTER_PATH, 0644);
|
||||
}
|
||||
if (!file_exists(self::UCP_SECURITY_LOG)) {
|
||||
@touch(self::UCP_SECURITY_LOG);
|
||||
}
|
||||
@chown(self::UCP_SECURITY_LOG, 'www-data');
|
||||
@chgrp(self::UCP_SECURITY_LOG, 'asterisk');
|
||||
@chmod(self::UCP_SECURITY_LOG, 0664);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ouvre/ferme les ports UCP configurés dans UFW / iptables uniquement si un filtrage actif existe.
|
||||
*/
|
||||
private static function syncUcpFirewallPorts(array $oldSettings, array $newSettings): void
|
||||
{
|
||||
$oldPorts = [];
|
||||
if (!empty($oldSettings['ucp_enabled'])) {
|
||||
if ((int)($oldSettings['ucp_https_port'] ?? 0) > 0) $oldPorts[] = (int)$oldSettings['ucp_https_port'];
|
||||
if ((int)($oldSettings['ucp_http_port'] ?? 0) > 0) $oldPorts[] = (int)$oldSettings['ucp_http_port'];
|
||||
}
|
||||
$newPorts = [];
|
||||
if (!empty($newSettings['ucp_enabled'])) {
|
||||
if ((int)($newSettings['ucp_https_port'] ?? 0) > 0) $newPorts[] = (int)$newSettings['ucp_https_port'];
|
||||
if ((int)($newSettings['ucp_http_port'] ?? 0) > 0) $newPorts[] = (int)$newSettings['ucp_http_port'];
|
||||
}
|
||||
|
||||
$toRemove = array_diff(array_unique($oldPorts), array_unique($newPorts));
|
||||
$toAdd = array_diff(array_unique($newPorts), array_unique($oldPorts));
|
||||
|
||||
// UFW si installé et actif
|
||||
$ufwStatus = trim((string)@shell_exec('sudo -n /usr/sbin/ufw status 2>/dev/null'));
|
||||
if ($ufwStatus !== '' && stripos($ufwStatus, 'Status: active') !== false) {
|
||||
foreach ($toRemove as $p) {
|
||||
@shell_exec('sudo -n /usr/sbin/ufw delete allow ' . (int)$p . '/tcp >/dev/null 2>&1');
|
||||
}
|
||||
foreach ($newPorts as $p) {
|
||||
@shell_exec('sudo -n /usr/sbin/ufw allow ' . (int)$p . '/tcp >/dev/null 2>&1');
|
||||
}
|
||||
}
|
||||
|
||||
// iptables si la politique INPUT par défaut bloque le trafic (DROP/REJECT)
|
||||
$iptPolicy = trim((string)@shell_exec("sudo -n /usr/sbin/iptables -S INPUT 2>/dev/null | head -n 1"));
|
||||
if (str_contains($iptPolicy, 'DROP') || str_contains($iptPolicy, 'REJECT')) {
|
||||
foreach ($toRemove as $p) {
|
||||
@shell_exec("sudo -n /usr/sbin/iptables -D INPUT -p tcp --dport " . (int)$p . " -j ACCEPT >/dev/null 2>&1");
|
||||
}
|
||||
foreach ($toAdd as $p) {
|
||||
@shell_exec("sudo -n /usr/sbin/iptables -C INPUT -p tcp --dport " . (int)$p . " -j ACCEPT >/dev/null 2>&1 || sudo -n /usr/sbin/iptables -I INPUT -p tcp --dport " . (int)$p . " -j ACCEPT >/dev/null 2>&1");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Résout l'IP réelle du client en tenant compte d'un reverse proxy local / RFC1918 de confiance.
|
||||
*/
|
||||
public static function resolveClientIp(): string
|
||||
{
|
||||
$remote = trim((string)($_SERVER['REMOTE_ADDR'] ?? '127.0.0.1'));
|
||||
if ($remote === '' || !filter_var($remote, FILTER_VALIDATE_IP)) {
|
||||
return '127.0.0.1';
|
||||
}
|
||||
|
||||
// Ne faire confiance aux en-têtes proxy que si la connexion TCP provient d'un réseau privé ou loopback
|
||||
$isTrustedProxy = (
|
||||
filter_var($remote, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE) === false
|
||||
);
|
||||
|
||||
if ($isTrustedProxy) {
|
||||
$candidates = [];
|
||||
if (!empty($_SERVER['HTTP_X_REAL_IP'])) {
|
||||
$candidates[] = trim((string)$_SERVER['HTTP_X_REAL_IP']);
|
||||
}
|
||||
if (!empty($_SERVER['HTTP_X_FORWARDED_FOR'])) {
|
||||
foreach (explode(',', (string)$_SERVER['HTTP_X_FORWARDED_FOR']) as $part) {
|
||||
$candidates[] = trim($part);
|
||||
}
|
||||
}
|
||||
foreach ($candidates as $cand) {
|
||||
if ($cand !== '' && filter_var($cand, FILTER_VALIDATE_IP)) {
|
||||
return $cand;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $remote;
|
||||
}
|
||||
|
||||
private static function getJailDetails(string $jail): array
|
||||
{
|
||||
exec('timeout 2 sudo -n /usr/bin/fail2ban-client status ' . escapeshellarg($jail) . ' 2>&1', $out);
|
||||
@@ -526,7 +788,12 @@ EOF;
|
||||
$totalFailed = max($totalFailed, $logIncidents);
|
||||
} elseif ($jail === 'nexus-web') {
|
||||
$logIncidents = is_readable('/var/log/nexus_security.log')
|
||||
? (int)shell_exec("grep -c 'FailedLogin' /var/log/nexus_security.log 2>/dev/null")
|
||||
? (int)shell_exec("grep -c -E 'NEXUS-WEB-AUTH-FAIL|FailedLogin' /var/log/nexus_security.log 2>/dev/null")
|
||||
: 0;
|
||||
$totalFailed = max($totalFailed, $logIncidents);
|
||||
} elseif ($jail === 'nexus-ucp') {
|
||||
$logIncidents = is_readable(self::UCP_SECURITY_LOG)
|
||||
? (int)shell_exec("grep -c 'NEXUS-UCP-AUTH-FAIL' " . escapeshellarg(self::UCP_SECURITY_LOG) . " 2>/dev/null")
|
||||
: 0;
|
||||
$totalFailed = max($totalFailed, $logIncidents);
|
||||
}
|
||||
@@ -558,6 +825,7 @@ EOF;
|
||||
return match ($jail) {
|
||||
'asterisk' => 'Téléphonie SIP (PJSIP)',
|
||||
'nexus-web' => 'Interface Web Admin',
|
||||
'nexus-ucp' => 'Portail Utilisateur (UCP)',
|
||||
'sshd' => 'Accès Console SSH',
|
||||
default => ucfirst($jail),
|
||||
};
|
||||
|
||||
+196
@@ -0,0 +1,196 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App;
|
||||
|
||||
use PDO;
|
||||
use InvalidArgumentException;
|
||||
|
||||
class UcpAuth
|
||||
{
|
||||
public const SESSION_NAME = 'NEXUS_UCP_SESS';
|
||||
|
||||
public static function startSession(): void
|
||||
{
|
||||
if (session_status() === PHP_SESSION_NONE) {
|
||||
session_name(self::SESSION_NAME);
|
||||
session_start([
|
||||
'cookie_httponly' => true,
|
||||
'cookie_samesite' => 'Lax',
|
||||
'cookie_path' => '/',
|
||||
]);
|
||||
}
|
||||
if (empty($_SESSION['ucp_csrf'])) {
|
||||
$_SESSION['ucp_csrf'] = bin2hex(random_bytes(24));
|
||||
}
|
||||
}
|
||||
|
||||
public static function getCsrfToken(): string
|
||||
{
|
||||
self::startSession();
|
||||
return (string)$_SESSION['ucp_csrf'];
|
||||
}
|
||||
|
||||
public static function verifyCsrfToken(string $token): bool
|
||||
{
|
||||
self::startSession();
|
||||
$expected = (string)($_SESSION['ucp_csrf'] ?? '');
|
||||
return $expected !== '' && hash_equals($expected, $token);
|
||||
}
|
||||
|
||||
public static function isUcpEnabled(PDO $db): bool
|
||||
{
|
||||
try {
|
||||
$val = $db->query('SELECT ucp_enabled FROM settings WHERE id = 1')->fetchColumn();
|
||||
return $val === false ? true : ((int)$val === 1);
|
||||
} catch (\Throwable) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
public static function getCurrentUser(PDO $db): ?array
|
||||
{
|
||||
self::startSession();
|
||||
if (empty($_SESSION['ucp_user_id']) || !self::isUcpEnabled($db)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$stmt = $db->prepare('
|
||||
SELECT id, username, display_name, email, extensions_json, enabled, last_login_at, last_login_ip
|
||||
FROM ucp_users
|
||||
WHERE id = :id
|
||||
LIMIT 1
|
||||
');
|
||||
$stmt->execute([':id' => (int)$_SESSION['ucp_user_id']]);
|
||||
$user = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
if (!$user || (int)$user['enabled'] !== 1) {
|
||||
self::logout();
|
||||
return null;
|
||||
}
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
public static function isAuthenticated(PDO $db): bool
|
||||
{
|
||||
return self::getCurrentUser($db) !== null;
|
||||
}
|
||||
|
||||
public static function login(PDO $db, string $username, string $password): bool
|
||||
{
|
||||
self::startSession();
|
||||
if (!self::isUcpEnabled($db)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$cleanUser = trim($username);
|
||||
$stmt = $db->prepare('
|
||||
SELECT id, username, display_name, password_hash, enabled
|
||||
FROM ucp_users
|
||||
WHERE username = :u
|
||||
LIMIT 1
|
||||
');
|
||||
$stmt->execute([':u' => $cleanUser]);
|
||||
$user = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
if ($user && (int)$user['enabled'] === 1 && password_verify($password, (string)$user['password_hash'])) {
|
||||
session_regenerate_id(true);
|
||||
$_SESSION['ucp_user_id'] = (int)$user['id'];
|
||||
$_SESSION['ucp_username'] = (string)$user['username'];
|
||||
if (empty($_SESSION['ucp_csrf'])) {
|
||||
$_SESSION['ucp_csrf'] = bin2hex(random_bytes(24));
|
||||
}
|
||||
|
||||
$ip = FirewallManager::resolveClientIp();
|
||||
$db->prepare('UPDATE ucp_users SET last_login_at = CURRENT_TIMESTAMP, last_login_ip = :ip WHERE id = :id')
|
||||
->execute([':ip' => $ip, ':id' => (int)$user['id']]);
|
||||
return true;
|
||||
}
|
||||
|
||||
self::logAuthFailure($cleanUser);
|
||||
return false;
|
||||
}
|
||||
|
||||
public static function logAuthFailure(string $username): void
|
||||
{
|
||||
$clientIp = FirewallManager::resolveClientIp();
|
||||
$safeUser = preg_replace('/[^a-zA-Z0-9._@\-]/', '', $username) ?: 'vide';
|
||||
$logMsg = date('[Y-m-d H:i:s] ') . "[NEXUS-UCP-AUTH-FAIL]: {$clientIp} (username: {$safeUser})\n";
|
||||
@file_put_contents(FirewallManager::UCP_SECURITY_LOG, $logMsg, FILE_APPEND | LOCK_EX);
|
||||
@chmod(FirewallManager::UCP_SECURITY_LOG, 0664);
|
||||
}
|
||||
|
||||
public static function logout(): void
|
||||
{
|
||||
self::startSession();
|
||||
$_SESSION = [];
|
||||
if (ini_get('session.use_cookies')) {
|
||||
$params = session_get_cookie_params();
|
||||
setcookie(
|
||||
session_name(),
|
||||
'',
|
||||
time() - 42000,
|
||||
$params['path'],
|
||||
$params['domain'],
|
||||
$params['secure'],
|
||||
$params['httponly']
|
||||
);
|
||||
}
|
||||
session_destroy();
|
||||
}
|
||||
|
||||
/**
|
||||
* Retourne la liste fraîche des extensions autorisées et actives pour un utilisateur UCP.
|
||||
*/
|
||||
public static function getAllowedExtensions(PDO $db, int $ucpUserId): array
|
||||
{
|
||||
$stmt = $db->prepare('SELECT extensions_json, enabled FROM ucp_users WHERE id = :id LIMIT 1');
|
||||
$stmt->execute([':id' => $ucpUserId]);
|
||||
$row = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
if (!$row || (int)$row['enabled'] !== 1) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$linked = json_decode((string)($row['extensions_json'] ?? '[]'), true);
|
||||
if (!is_array($linked) || empty($linked)) {
|
||||
return [];
|
||||
}
|
||||
$linkedSet = array_values(array_unique(array_map('strval', $linked)));
|
||||
|
||||
$extRows = $db->query('
|
||||
SELECT id, extension, name, transport, encryption, voicemail_enabled, voicemail_email, voicemail_pin, ring_time
|
||||
FROM extensions
|
||||
WHERE enabled = 1
|
||||
ORDER BY extension ASC
|
||||
')->fetchAll(PDO::FETCH_ASSOC) ?: [];
|
||||
|
||||
$allowed = [];
|
||||
foreach ($extRows as $ext) {
|
||||
if (in_array((string)$ext['extension'], $linkedSet, true)) {
|
||||
$allowed[] = $ext;
|
||||
}
|
||||
}
|
||||
return $allowed;
|
||||
}
|
||||
|
||||
public static function updateOwnPassword(PDO $db, int $ucpUserId, string $currentPassword, string $newPassword): void
|
||||
{
|
||||
if (strlen($newPassword) < 6) {
|
||||
throw new InvalidArgumentException("Le nouveau mot de passe doit contenir au moins 6 caractères.");
|
||||
}
|
||||
|
||||
$stmt = $db->prepare('SELECT password_hash FROM ucp_users WHERE id = :id LIMIT 1');
|
||||
$stmt->execute([':id' => $ucpUserId]);
|
||||
$hash = $stmt->fetchColumn();
|
||||
if (!$hash || !password_verify($currentPassword, (string)$hash)) {
|
||||
throw new InvalidArgumentException("Le mot de passe actuel est incorrect.");
|
||||
}
|
||||
|
||||
$db->prepare('UPDATE ucp_users SET password_hash = :p, updated_at = CURRENT_TIMESTAMP WHERE id = :id')
|
||||
->execute([
|
||||
':p' => password_hash($newPassword, PASSWORD_DEFAULT),
|
||||
':id' => $ucpUserId,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,787 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App;
|
||||
|
||||
use PDO;
|
||||
use InvalidArgumentException;
|
||||
use RuntimeException;
|
||||
|
||||
class UcpManager
|
||||
{
|
||||
public const VOICEMAIL_SPOOL_BASE = '/var/spool/asterisk/voicemail/default';
|
||||
|
||||
// =========================================================================
|
||||
// 1. GESTION ADMIN DES UTILISATEURS UCP
|
||||
// =========================================================================
|
||||
|
||||
public static function listUsers(PDO $db): array
|
||||
{
|
||||
$users = $db->query('
|
||||
SELECT id, username, display_name, email, extensions_json, enabled, last_login_at, last_login_ip, created_at, updated_at
|
||||
FROM ucp_users
|
||||
ORDER BY username ASC
|
||||
')->fetchAll(PDO::FETCH_ASSOC) ?: [];
|
||||
|
||||
$extRows = $db->query('SELECT extension, name, enabled FROM extensions ORDER BY extension ASC')->fetchAll(PDO::FETCH_ASSOC) ?: [];
|
||||
$extMap = [];
|
||||
foreach ($extRows as $e) {
|
||||
$extMap[(string)$e['extension']] = [
|
||||
'extension' => (string)$e['extension'],
|
||||
'name' => (string)$e['name'],
|
||||
'enabled' => (int)$e['enabled'],
|
||||
];
|
||||
}
|
||||
|
||||
foreach ($users as &$u) {
|
||||
$exts = json_decode((string)($u['extensions_json'] ?? '[]'), true);
|
||||
$u['extensions'] = is_array($exts) ? array_values(array_map('strval', $exts)) : [];
|
||||
$u['extensions_meta'] = array_values(array_filter(array_map(
|
||||
fn(string $num) => $extMap[$num] ?? ['extension' => $num, 'name' => 'Poste inconnu', 'enabled' => 0],
|
||||
$u['extensions']
|
||||
)));
|
||||
$u['enabled'] = (int)$u['enabled'];
|
||||
unset($u['extensions_json']);
|
||||
}
|
||||
unset($u);
|
||||
|
||||
return $users;
|
||||
}
|
||||
|
||||
public static function saveUser(PDO $db, array $data): array
|
||||
{
|
||||
$id = isset($data['id']) && (int)$data['id'] > 0 ? (int)$data['id'] : null;
|
||||
$username = trim((string)($data['username'] ?? ''));
|
||||
$displayName = trim((string)($data['display_name'] ?? ''));
|
||||
$email = trim((string)($data['email'] ?? ''));
|
||||
$password = (string)($data['password'] ?? '');
|
||||
$enabled = isset($data['enabled']) ? (!empty($data['enabled']) ? 1 : 0) : 1;
|
||||
$rawExts = is_array($data['extensions'] ?? null) ? $data['extensions'] : [];
|
||||
|
||||
if (!preg_match('/^[a-zA-Z0-9._@\-]{2,64}$/', $username)) {
|
||||
throw new InvalidArgumentException("L'identifiant UCP doit contenir 2 à 64 caractères (lettres, chiffres, . _ @ -).");
|
||||
}
|
||||
if ($email !== '' && !filter_var($email, FILTER_VALIDATE_EMAIL)) {
|
||||
throw new InvalidArgumentException("Adresse e-mail invalide.");
|
||||
}
|
||||
if (!$id && strlen($password) < 6) {
|
||||
throw new InvalidArgumentException("Le mot de passe initial doit contenir au moins 6 caractères.");
|
||||
}
|
||||
if ($id && $password !== '' && strlen($password) < 6) {
|
||||
throw new InvalidArgumentException("Le nouveau mot de passe doit contenir au moins 6 caractères.");
|
||||
}
|
||||
|
||||
// Ne conserver que des extensions valides existantes
|
||||
$validExts = $db->query('SELECT extension FROM extensions')->fetchAll(PDO::FETCH_COLUMN) ?: [];
|
||||
$validSet = array_map('strval', $validExts);
|
||||
$cleanExts = [];
|
||||
foreach ($rawExts as $ext) {
|
||||
$extStr = trim((string)$ext);
|
||||
if ($extStr !== '' && in_array($extStr, $validSet, true) && !in_array($extStr, $cleanExts, true)) {
|
||||
$cleanExts[] = $extStr;
|
||||
}
|
||||
}
|
||||
if (empty($cleanExts)) {
|
||||
throw new InvalidArgumentException("Veuillez lier au moins un poste SIP à cet utilisateur UCP.");
|
||||
}
|
||||
|
||||
$extJson = json_encode($cleanExts);
|
||||
|
||||
// Vérification d'unicité du username
|
||||
$dupStmt = $db->prepare('SELECT id FROM ucp_users WHERE username = :u' . ($id ? ' AND id != :id' : ''));
|
||||
$dupParams = [':u' => $username];
|
||||
if ($id) {
|
||||
$dupParams[':id'] = $id;
|
||||
}
|
||||
$dupStmt->execute($dupParams);
|
||||
if ($dupStmt->fetchColumn()) {
|
||||
throw new InvalidArgumentException("Un compte UCP avec l'identifiant « {$username} » existe déjà.");
|
||||
}
|
||||
|
||||
if ($id) {
|
||||
if ($password !== '') {
|
||||
$stmt = $db->prepare('
|
||||
UPDATE ucp_users
|
||||
SET username = :u, display_name = :dn, email = :em, password_hash = :ph,
|
||||
extensions_json = :exts, enabled = :en, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = :id
|
||||
');
|
||||
$stmt->execute([
|
||||
':id' => $id,
|
||||
':u' => $username,
|
||||
':dn' => $displayName ?: $username,
|
||||
':em' => $email,
|
||||
':ph' => password_hash($password, PASSWORD_DEFAULT),
|
||||
':exts' => $extJson,
|
||||
':en' => $enabled,
|
||||
]);
|
||||
} else {
|
||||
$stmt = $db->prepare('
|
||||
UPDATE ucp_users
|
||||
SET username = :u, display_name = :dn, email = :em,
|
||||
extensions_json = :exts, enabled = :en, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = :id
|
||||
');
|
||||
$stmt->execute([
|
||||
':id' => $id,
|
||||
':u' => $username,
|
||||
':dn' => $displayName ?: $username,
|
||||
':em' => $email,
|
||||
':exts' => $extJson,
|
||||
':en' => $enabled,
|
||||
]);
|
||||
}
|
||||
} else {
|
||||
$stmt = $db->prepare('
|
||||
INSERT INTO ucp_users (username, display_name, email, password_hash, extensions_json, enabled)
|
||||
VALUES (:u, :dn, :em, :ph, :exts, :en)
|
||||
');
|
||||
$stmt->execute([
|
||||
':u' => $username,
|
||||
':dn' => $displayName ?: $username,
|
||||
':em' => $email,
|
||||
':ph' => password_hash($password, PASSWORD_DEFAULT),
|
||||
':exts' => $extJson,
|
||||
':en' => $enabled,
|
||||
]);
|
||||
}
|
||||
|
||||
return self::listUsers($db);
|
||||
}
|
||||
|
||||
public static function deleteUser(PDO $db, int $id): array
|
||||
{
|
||||
$db->prepare('DELETE FROM ucp_users WHERE id = :id')->execute([':id' => $id]);
|
||||
return self::listUsers($db);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// 2. ÉTAT TEMPS RÉEL DES POSTES & RENVOIS (DND / CF / CFU)
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Retourne l'état live (en ligne, latence, IP, DND, CF, CFU, compteurs messagerie) des extensions autorisées.
|
||||
*/
|
||||
public static function getExtensionsState(PDO $db, array $allowedExts): array
|
||||
{
|
||||
$extNums = array_map(fn($e) => (string)$e['extension'], $allowedExts);
|
||||
if (empty($extNums)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$contactsByExt = [];
|
||||
$dndMap = [];
|
||||
$cfMap = [];
|
||||
$cfuMap = [];
|
||||
|
||||
try {
|
||||
$ami = AmiClient::fromDatabase($db);
|
||||
|
||||
// Contacts PJSIP & métadonnées AstDB
|
||||
$rawContacts = $ami->runCommand('pjsip show contacts');
|
||||
$rawRegMeta = $ami->runCommand('database show registrar/contact');
|
||||
$contactMeta = [];
|
||||
foreach (preg_split('/\r?\n/', $rawRegMeta) ?: [] as $dbLine) {
|
||||
if (preg_match('/^\/registrar\/contact\/([^;]+);@[a-f0-9]+:\s*(\{.*\})/i', $dbLine, $dbM)) {
|
||||
$parsed = json_decode($dbM[2], true);
|
||||
if ($parsed) {
|
||||
$contactMeta[$dbM[1]] = $parsed;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
foreach (preg_split('/\r?\n/', $rawContacts) ?: [] as $line) {
|
||||
if (preg_match('/Contact:\s+([^\s\/]+)\/(?:sips?:)?([^\s]+)\s+([a-zA-Z0-9]+)\s+(\S+)(?:\s+(\S+))?/i', $line, $m)) {
|
||||
$extNum = $m[1];
|
||||
if (!in_array($extNum, $extNums, true)) {
|
||||
continue;
|
||||
}
|
||||
$status = $m[4];
|
||||
$rttRaw = $m[5] ?? null;
|
||||
$isAvail = (stripos($status, 'Avail') !== false && stripos($status, 'Unavail') === false) || stripos($status, 'NonQual') !== false;
|
||||
$meta = $contactMeta[$extNum] ?? [];
|
||||
$fullUri = $meta['uri'] ?? ('sip:' . $m[2]);
|
||||
|
||||
$ip = '';
|
||||
if (preg_match('/@([^:;>]+)/', $fullUri, $ipM)) {
|
||||
$ip = $ipM[1];
|
||||
}
|
||||
|
||||
$contactsByExt[$extNum] = [
|
||||
'online' => $isAvail,
|
||||
'status' => $status,
|
||||
'rtt_ms' => ($rttRaw !== null && is_numeric($rttRaw)) ? round((float)$rttRaw, 1) : null,
|
||||
'ip' => $ip,
|
||||
'user_agent' => (string)($meta['user_agent'] ?? ''),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
// États DND / CF / CFU depuis AstDB
|
||||
$dndMap = self::parseAstDbPrefix($ami->runCommand('database show DND'), '/DND/');
|
||||
$cfMap = self::parseAstDbPrefix($ami->runCommand('database show CF'), '/CF/');
|
||||
$cfuMap = self::parseAstDbPrefix($ami->runCommand('database show CFU'), '/CFU/');
|
||||
} catch (\Throwable) {
|
||||
// Asterisk indisponible : on renvoie les métadonnées locales sans bloquer l'interface
|
||||
}
|
||||
|
||||
// Photo & email annuaire liés
|
||||
$pbByExtId = [];
|
||||
try {
|
||||
$pbRows = $db->query('SELECT extension_id, email, photo_url FROM phonebook_contacts WHERE is_internal = 1 AND extension_id IS NOT NULL')
|
||||
->fetchAll(PDO::FETCH_ASSOC) ?: [];
|
||||
foreach ($pbRows as $p) {
|
||||
$pbByExtId[(int)$p['extension_id']] = $p;
|
||||
}
|
||||
} catch (\Throwable) {}
|
||||
|
||||
$result = [];
|
||||
foreach ($allowedExts as $ext) {
|
||||
$num = (string)$ext['extension'];
|
||||
$extId = (int)$ext['id'];
|
||||
$cInfo = $contactsByExt[$num] ?? ['online' => false, 'status' => 'Unavailable', 'rtt_ms' => null, 'ip' => '', 'user_agent' => ''];
|
||||
$vmCounts = !empty($ext['voicemail_enabled']) ? self::countVoicemails($num) : ['inbox' => 0, 'old' => 0];
|
||||
$pb = $pbByExtId[$extId] ?? [];
|
||||
|
||||
$result[] = [
|
||||
'id' => $extId,
|
||||
'extension' => $num,
|
||||
'name' => (string)$ext['name'],
|
||||
'transport' => (string)$ext['transport'],
|
||||
'online' => $cInfo['online'],
|
||||
'status_label' => $cInfo['status'],
|
||||
'rtt_ms' => $cInfo['rtt_ms'],
|
||||
'ip' => $cInfo['ip'],
|
||||
'user_agent' => $cInfo['user_agent'],
|
||||
'dnd' => strtoupper((string)($dndMap[$num] ?? '')) === 'YES',
|
||||
'cf' => (string)($cfMap[$num] ?? ''),
|
||||
'cfu' => (string)($cfuMap[$num] ?? ''),
|
||||
'voicemail_enabled' => (int)$ext['voicemail_enabled'],
|
||||
'voicemail_email' => (string)($ext['voicemail_email'] ?? ''),
|
||||
'vm_inbox_count' => $vmCounts['inbox'],
|
||||
'vm_old_count' => $vmCounts['old'],
|
||||
'profile_email' => (string)($pb['email'] ?? ''),
|
||||
'photo_url' => (string)($pb['photo_url'] ?? ''),
|
||||
];
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retourne la liste des cibles de renvoi autorisées (postes internes + répondeurs vm-<ext>).
|
||||
*/
|
||||
public static function getForwardTargets(PDO $db): array
|
||||
{
|
||||
$rows = $db->query('SELECT extension, name, voicemail_enabled FROM extensions WHERE enabled = 1 ORDER BY extension ASC')
|
||||
->fetchAll(PDO::FETCH_ASSOC) ?: [];
|
||||
$targets = [];
|
||||
foreach ($rows as $r) {
|
||||
$targets[] = [
|
||||
'extension' => (string)$r['extension'],
|
||||
'name' => (string)$r['name'],
|
||||
'voicemail_enabled' => (int)$r['voicemail_enabled'],
|
||||
];
|
||||
}
|
||||
return $targets;
|
||||
}
|
||||
|
||||
/**
|
||||
* Applique DND, CF et CFU sur une extension autorisée via AMI + mise à jour BLF *76<ext>.
|
||||
*/
|
||||
public static function setCallFeatures(PDO $db, array $allowedExts, string $ext, array $features): void
|
||||
{
|
||||
$ext = self::assertAllowedExtension($allowedExts, $ext);
|
||||
|
||||
$dnd = !empty($features['dnd']);
|
||||
$cf = trim((string)($features['cf'] ?? ''));
|
||||
$cfu = trim((string)($features['cfu'] ?? ''));
|
||||
|
||||
self::validateForwardTarget($db, $ext, $cf, 'Renvoi inconditionnel (CF)');
|
||||
self::validateForwardTarget($db, $ext, $cfu, 'Renvoi sur non-réponse (CFU)');
|
||||
|
||||
$ami = AmiClient::fromDatabase($db);
|
||||
|
||||
// DND + mise à jour état BLF *76<ext> en direct
|
||||
if ($dnd) {
|
||||
$ami->runCommand("database put DND {$ext} YES");
|
||||
$ami->runCommand("devstate change Custom:DND{$ext} BUSY");
|
||||
} else {
|
||||
$ami->runCommand("database del DND {$ext}");
|
||||
$ami->runCommand("devstate change Custom:DND{$ext} NOT_INUSE");
|
||||
}
|
||||
|
||||
// CF (Renvoi inconditionnel)
|
||||
if ($cf !== '') {
|
||||
$ami->runCommand("database put CF {$ext} {$cf}");
|
||||
} else {
|
||||
$ami->runCommand("database del CF {$ext}");
|
||||
}
|
||||
|
||||
// CFU (Renvoi non-réponse / indisponible)
|
||||
if ($cfu !== '') {
|
||||
$ami->runCommand("database put CFU {$ext} {$cfu}");
|
||||
} else {
|
||||
$ami->runCommand("database del CFU {$ext}");
|
||||
}
|
||||
}
|
||||
|
||||
private static function validateForwardTarget(PDO $db, string $sourceExt, string $target, string $label): void
|
||||
{
|
||||
if ($target === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
// Cible répondeur direct vm-<ext>
|
||||
if (str_starts_with($target, 'vm-')) {
|
||||
$vmExt = substr($target, 3);
|
||||
if (!preg_match('/^\d{2,6}$/', $vmExt)) {
|
||||
throw new InvalidArgumentException("{$label} : boîte vocale cible invalide.");
|
||||
}
|
||||
$stmt = $db->prepare('SELECT voicemail_enabled FROM extensions WHERE extension = :ext AND enabled = 1 LIMIT 1');
|
||||
$stmt->execute([':ext' => $vmExt]);
|
||||
if (!(int)$stmt->fetchColumn()) {
|
||||
throw new InvalidArgumentException("{$label} : le répondeur du poste {$vmExt} n'est pas activé.");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Cible poste interne uniquement (sécurité anti-fraude UCP)
|
||||
if (!preg_match('/^\d{2,6}$/', $target)) {
|
||||
throw new InvalidArgumentException("{$label} : seuls les postes internes ou répondeurs sont autorisés.");
|
||||
}
|
||||
if ($target === $sourceExt) {
|
||||
throw new InvalidArgumentException("{$label} : impossible de renvoyer un poste vers lui-même.");
|
||||
}
|
||||
$stmt = $db->prepare('SELECT id FROM extensions WHERE extension = :ext AND enabled = 1 LIMIT 1');
|
||||
$stmt->execute([':ext' => $target]);
|
||||
if (!$stmt->fetchColumn()) {
|
||||
throw new InvalidArgumentException("{$label} : le poste interne {$target} n'existe pas ou est désactivé.");
|
||||
}
|
||||
}
|
||||
|
||||
private static function parseAstDbPrefix(string $rawOutput, string $prefix): array
|
||||
{
|
||||
$map = [];
|
||||
foreach (preg_split('/\r?\n/', $rawOutput) ?: [] as $line) {
|
||||
$pos = strpos($line, $prefix);
|
||||
if ($pos === 0 && str_contains($line, ':')) {
|
||||
$rest = substr($line, strlen($prefix));
|
||||
[$k, $v] = explode(':', $rest, 2);
|
||||
$k = trim($k);
|
||||
$v = trim($v);
|
||||
if ($k !== '' && !str_contains($k, ' ')) {
|
||||
$map[$k] = $v;
|
||||
}
|
||||
}
|
||||
}
|
||||
return $map;
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// 3. MESSAGERIE VOCALE (LECTURE, TÉLÉCHARGEMENT, ARCHIVAGE, SUPPRESSION, PIN)
|
||||
// =========================================================================
|
||||
|
||||
public static function countVoicemails(string $ext): array
|
||||
{
|
||||
return [
|
||||
'inbox' => count(glob(self::VOICEMAIL_SPOOL_BASE . "/{$ext}/INBOX/msg*.txt") ?: []),
|
||||
'old' => count(glob(self::VOICEMAIL_SPOOL_BASE . "/{$ext}/Old/msg*.txt") ?: []),
|
||||
];
|
||||
}
|
||||
|
||||
public static function getVoicemails(array $allowedExts, string $ext): array
|
||||
{
|
||||
$ext = self::assertAllowedExtension($allowedExts, $ext);
|
||||
$messages = [];
|
||||
|
||||
foreach (['INBOX', 'Old'] as $folder) {
|
||||
$dir = self::VOICEMAIL_SPOOL_BASE . "/{$ext}/{$folder}";
|
||||
if (!is_dir($dir)) {
|
||||
continue;
|
||||
}
|
||||
$txtFiles = glob($dir . '/msg[0-9][0-9][0-9][0-9].txt') ?: [];
|
||||
foreach ($txtFiles as $txtFile) {
|
||||
$msgId = basename($txtFile, '.txt');
|
||||
if (!preg_match('/^msg\d{4}$/', $msgId)) {
|
||||
continue;
|
||||
}
|
||||
$meta = self::parseVoicemailTxt($txtFile);
|
||||
$audioPath = self::findVoicemailAudioFile($dir, $msgId);
|
||||
if (!$audioPath) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$origTime = (int)($meta['origtime'] ?? @filemtime($audioPath));
|
||||
$messages[] = [
|
||||
'id' => $msgId,
|
||||
'extension' => $ext,
|
||||
'folder' => $folder,
|
||||
'is_new' => ($folder === 'INBOX'),
|
||||
'callerid' => self::formatVmCallerId((string)($meta['callerid'] ?? 'Inconnu')),
|
||||
'origtime' => $origTime,
|
||||
'date_iso' => $origTime > 0 ? date('c', $origTime) : '',
|
||||
'date_human' => $origTime > 0 ? date('d/m/Y H:i', $origTime) : '—',
|
||||
'duration' => (int)($meta['duration'] ?? 0),
|
||||
'size_bytes' => (int)@filesize($audioPath),
|
||||
'format' => strtolower(pathinfo($audioPath, PATHINFO_EXTENSION)),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
usort($messages, fn($a, $b) => $b['origtime'] <=> $a['origtime']);
|
||||
return $messages;
|
||||
}
|
||||
|
||||
public static function getVoicemailAudioPath(array $allowedExts, string $ext, string $folder, string $msgId): string
|
||||
{
|
||||
$ext = self::assertAllowedExtension($allowedExts, $ext);
|
||||
$folder = self::assertValidVmFolder($folder);
|
||||
$msgId = self::assertValidMsgId($msgId);
|
||||
|
||||
$dir = self::VOICEMAIL_SPOOL_BASE . "/{$ext}/{$folder}";
|
||||
$path = self::findVoicemailAudioFile($dir, $msgId);
|
||||
if (!$path || !is_readable($path)) {
|
||||
throw new RuntimeException("Message vocal introuvable sur le disque.");
|
||||
}
|
||||
return $path;
|
||||
}
|
||||
|
||||
public static function moveVoicemailFolder(PDO $db, array $allowedExts, string $ext, string $fromFolder, string $msgId): void
|
||||
{
|
||||
$ext = self::assertAllowedExtension($allowedExts, $ext);
|
||||
$fromFolder = self::assertValidVmFolder($fromFolder);
|
||||
$msgId = self::assertValidMsgId($msgId);
|
||||
$toFolder = ($fromFolder === 'INBOX') ? 'Old' : 'INBOX';
|
||||
|
||||
$srcDir = self::VOICEMAIL_SPOOL_BASE . "/{$ext}/{$fromFolder}";
|
||||
$dstDir = self::VOICEMAIL_SPOOL_BASE . "/{$ext}/{$toFolder}";
|
||||
|
||||
if (!is_dir($dstDir)) {
|
||||
@mkdir($dstDir, 0775, true);
|
||||
@chown($dstDir, 'asterisk');
|
||||
@chgrp($dstDir, 'asterisk');
|
||||
}
|
||||
|
||||
$srcFiles = glob("{$srcDir}/{$msgId}.*") ?: [];
|
||||
if (empty($srcFiles)) {
|
||||
throw new RuntimeException("Message vocal {$msgId} introuvable dans {$fromFolder}.");
|
||||
}
|
||||
|
||||
// Trouver le prochain index msgNNNN libre dans le dossier cible
|
||||
$nextIdx = 0;
|
||||
while (
|
||||
!empty(glob(sprintf("{$dstDir}/msg%04d.*", $nextIdx)))
|
||||
&& $nextIdx < 9999
|
||||
) {
|
||||
$nextIdx++;
|
||||
}
|
||||
$newMsgId = sprintf('msg%04d', $nextIdx);
|
||||
|
||||
foreach ($srcFiles as $srcFile) {
|
||||
$fileExt = pathinfo($srcFile, PATHINFO_EXTENSION);
|
||||
$dstFile = "{$dstDir}/{$newMsgId}.{$fileExt}";
|
||||
if (!@rename($srcFile, $dstFile)) {
|
||||
if (@copy($srcFile, $dstFile)) {
|
||||
@unlink($srcFile);
|
||||
} else {
|
||||
throw new RuntimeException("Impossible de déplacer le fichier vocal ({$fileExt}).");
|
||||
}
|
||||
}
|
||||
@chown($dstFile, 'asterisk');
|
||||
@chgrp($dstFile, 'asterisk');
|
||||
@chmod($dstFile, 0664);
|
||||
}
|
||||
|
||||
self::reloadVoicemailQuiet($db);
|
||||
}
|
||||
|
||||
public static function deleteVoicemail(PDO $db, array $allowedExts, string $ext, string $folder, string $msgId): void
|
||||
{
|
||||
$ext = self::assertAllowedExtension($allowedExts, $ext);
|
||||
$folder = self::assertValidVmFolder($folder);
|
||||
$msgId = self::assertValidMsgId($msgId);
|
||||
|
||||
$dir = self::VOICEMAIL_SPOOL_BASE . "/{$ext}/{$folder}";
|
||||
$files = glob("{$dir}/{$msgId}.*") ?: [];
|
||||
if (empty($files)) {
|
||||
throw new RuntimeException("Message vocal {$msgId} introuvable.");
|
||||
}
|
||||
foreach ($files as $f) {
|
||||
@unlink($f);
|
||||
}
|
||||
|
||||
self::reloadVoicemailQuiet($db);
|
||||
}
|
||||
|
||||
public static function updateVoicemailPrefs(PDO $db, array $allowedExts, string $ext, string $newPin, string $vmEmail): void
|
||||
{
|
||||
$ext = self::assertAllowedExtension($allowedExts, $ext);
|
||||
$cleanPin = trim($newPin);
|
||||
$cleanEmail = trim($vmEmail);
|
||||
|
||||
if ($cleanPin !== '' && !preg_match('/^\d{4,12}$/', $cleanPin)) {
|
||||
throw new InvalidArgumentException("Le code PIN messagerie doit contenir entre 4 et 12 chiffres.");
|
||||
}
|
||||
if ($cleanEmail !== '' && !filter_var($cleanEmail, FILTER_VALIDATE_EMAIL)) {
|
||||
throw new InvalidArgumentException("Adresse e-mail de notification invalide.");
|
||||
}
|
||||
|
||||
if ($cleanPin !== '') {
|
||||
$db->prepare('UPDATE extensions SET voicemail_pin = :pin, voicemail_email = :em, force_pin_change = 0 WHERE extension = :ext')
|
||||
->execute([':pin' => $cleanPin, ':em' => $cleanEmail, ':ext' => $ext]);
|
||||
|
||||
$secFile = self::VOICEMAIL_SPOOL_BASE . "/{$ext}/secret.conf";
|
||||
if (is_dir(dirname($secFile))) {
|
||||
@file_put_contents($secFile, "[general]\npassword={$cleanPin}\n");
|
||||
@chown($secFile, 'asterisk');
|
||||
@chgrp($secFile, 'asterisk');
|
||||
@chmod($secFile, 0664);
|
||||
}
|
||||
} else {
|
||||
$db->prepare('UPDATE extensions SET voicemail_email = :em WHERE extension = :ext')
|
||||
->execute([':em' => $cleanEmail, ':ext' => $ext]);
|
||||
}
|
||||
|
||||
// Régénération ciblée de voicemail.custom.conf + rechargement à chaud
|
||||
$gen = new ConfigGenerator($db);
|
||||
$vmContent = $gen->generateVoicemailConf();
|
||||
file_put_contents(ConfigGenerator::VOICEMAIL_CONF_PATH, $vmContent);
|
||||
@chgrp(ConfigGenerator::VOICEMAIL_CONF_PATH, 'asterisk');
|
||||
@chmod(ConfigGenerator::VOICEMAIL_CONF_PATH, 0664);
|
||||
|
||||
self::reloadVoicemailQuiet($db);
|
||||
}
|
||||
|
||||
private static function reloadVoicemailQuiet(PDO $db): void
|
||||
{
|
||||
try {
|
||||
AmiClient::fromDatabase($db)->runCommand('voicemail reload');
|
||||
} catch (\Throwable) {}
|
||||
}
|
||||
|
||||
private static function parseVoicemailTxt(string $path): array
|
||||
{
|
||||
$data = [];
|
||||
$lines = @file($path, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) ?: [];
|
||||
foreach ($lines as $line) {
|
||||
if (str_starts_with($line, ';') || str_starts_with($line, '[')) {
|
||||
continue;
|
||||
}
|
||||
if (str_contains($line, '=')) {
|
||||
[$k, $v] = explode('=', $line, 2);
|
||||
$data[trim(strtolower($k))] = trim($v);
|
||||
}
|
||||
}
|
||||
return $data;
|
||||
}
|
||||
|
||||
private static function findVoicemailAudioFile(string $dir, string $msgId): ?string
|
||||
{
|
||||
foreach (['wav', 'WAV', 'gsm'] as $audioExt) {
|
||||
$candidate = "{$dir}/{$msgId}.{$audioExt}";
|
||||
if (is_file($candidate)) {
|
||||
return $candidate;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static function formatVmCallerId(string $raw): string
|
||||
{
|
||||
$clean = trim($raw, " \t\n\r\0\x0B\"");
|
||||
if ($clean === '' || $clean === '<>') {
|
||||
return 'Appelant inconnu';
|
||||
}
|
||||
return $clean;
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// 4. JOURNAL D'APPELS PERSONNEL & ANNUAIRE / PROFIL
|
||||
// =========================================================================
|
||||
|
||||
public static function getPersonalCdr(PDO $db, array $allowedExts, array $filters = []): array
|
||||
{
|
||||
$extSet = array_map(fn($e) => (string)$e['extension'], $allowedExts);
|
||||
if (empty($extSet)) {
|
||||
return ['calls' => [], 'stats' => ['total' => 0, 'answered' => 0, 'missed' => 0]];
|
||||
}
|
||||
|
||||
$res = CdrManager::getCalls($db, [
|
||||
'limit' => 500,
|
||||
'direction' => (string)($filters['direction'] ?? 'all'),
|
||||
'disposition' => (string)($filters['disposition'] ?? 'all'),
|
||||
'period' => (string)($filters['period'] ?? 'all'),
|
||||
'search' => (string)($filters['search'] ?? ''),
|
||||
]);
|
||||
|
||||
$filtered = [];
|
||||
$answered = 0;
|
||||
$missed = 0;
|
||||
|
||||
foreach ($res['calls'] as $call) {
|
||||
$srcMatch = in_array((string)($call['src'] ?? ''), $extSet, true);
|
||||
$dstMatch = in_array((string)($call['dst'] ?? ''), $extSet, true);
|
||||
if (!$srcMatch && !$dstMatch) {
|
||||
continue;
|
||||
}
|
||||
if (($call['disposition'] ?? '') === 'ANSWERED') {
|
||||
$answered++;
|
||||
} else {
|
||||
$missed++;
|
||||
}
|
||||
$filtered[] = $call;
|
||||
if (count($filtered) >= 120) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'calls' => $filtered,
|
||||
'stats' => [
|
||||
'total' => count($filtered),
|
||||
'answered' => $answered,
|
||||
'missed' => $missed,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
public static function getPhonebookForUser(PDO $db, array $allowedExts): array
|
||||
{
|
||||
$allowedExtIds = array_map(fn($e) => (int)$e['id'], $allowedExts);
|
||||
$allowedMap = [];
|
||||
foreach ($allowedExts as $e) {
|
||||
$allowedMap[(int)$e['id']] = (string)$e['extension'];
|
||||
}
|
||||
|
||||
$contacts = PhonebookManager::getContacts($db);
|
||||
foreach ($contacts as &$c) {
|
||||
$eid = isset($c['extension_id']) ? (int)$c['extension_id'] : 0;
|
||||
$c['is_mine'] = ($eid > 0 && in_array($eid, $allowedExtIds, true));
|
||||
$c['mine_extension'] = $c['is_mine'] ? $allowedMap[$eid] : null;
|
||||
}
|
||||
unset($c);
|
||||
|
||||
return $contacts;
|
||||
}
|
||||
|
||||
public static function updateMyContactEmail(PDO $db, array $allowedExts, string $ext, string $email): void
|
||||
{
|
||||
$ext = self::assertAllowedExtension($allowedExts, $ext);
|
||||
$cleanEmail = trim($email);
|
||||
if ($cleanEmail !== '' && !filter_var($cleanEmail, FILTER_VALIDATE_EMAIL)) {
|
||||
throw new InvalidArgumentException("Adresse e-mail invalide.");
|
||||
}
|
||||
|
||||
PhonebookManager::syncExtensions($db);
|
||||
$extId = self::getExtensionId($allowedExts, $ext);
|
||||
|
||||
// Maintenir phonebook_contacts et extensions.voicemail_email alignés pour éviter un écrasement lors des syncExtensions()
|
||||
$db->prepare('UPDATE extensions SET voicemail_email = :em WHERE id = :eid')
|
||||
->execute([':em' => $cleanEmail, ':eid' => $extId]);
|
||||
$db->prepare('UPDATE phonebook_contacts SET email = :em, updated_at = CURRENT_TIMESTAMP WHERE extension_id = :eid AND is_internal = 1')
|
||||
->execute([':em' => $cleanEmail, ':eid' => $extId]);
|
||||
|
||||
$gen = new ConfigGenerator($db);
|
||||
$vmContent = $gen->generateVoicemailConf();
|
||||
file_put_contents(ConfigGenerator::VOICEMAIL_CONF_PATH, $vmContent);
|
||||
@chgrp(ConfigGenerator::VOICEMAIL_CONF_PATH, 'asterisk');
|
||||
@chmod(ConfigGenerator::VOICEMAIL_CONF_PATH, 0664);
|
||||
self::reloadVoicemailQuiet($db);
|
||||
}
|
||||
|
||||
public static function uploadMyAvatar(PDO $db, array $allowedExts, string $ext, array $file): string
|
||||
{
|
||||
$ext = self::assertAllowedExtension($allowedExts, $ext);
|
||||
|
||||
if (($file['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) {
|
||||
throw new RuntimeException("Erreur lors de l'envoi de la photo.");
|
||||
}
|
||||
if (($file['size'] ?? 0) > 5 * 1024 * 1024) {
|
||||
throw new InvalidArgumentException("La photo ne doit pas dépasser 5 Mo.");
|
||||
}
|
||||
|
||||
$finfo = new \finfo(FILEINFO_MIME_TYPE);
|
||||
$mime = $finfo->file((string)$file['tmp_name']);
|
||||
$allowed = ['image/jpeg' => 'jpg', 'image/png' => 'png', 'image/webp' => 'webp'];
|
||||
if (!isset($allowed[$mime])) {
|
||||
throw new InvalidArgumentException("Format non supporté. Utilisez JPG, PNG ou WebP.");
|
||||
}
|
||||
|
||||
PhonebookManager::initDirectory();
|
||||
PhonebookManager::syncExtensions($db);
|
||||
|
||||
// Nettoyer l'ancien avatar UCP de ce poste
|
||||
foreach (glob(PhonebookManager::AVATARS_DIR . "/avatar_ucp_{$ext}_*") ?: [] as $oldFile) {
|
||||
@unlink($oldFile);
|
||||
}
|
||||
|
||||
$fileExt = $allowed[$mime];
|
||||
$filename = "avatar_ucp_{$ext}_" . time() . ".{$fileExt}";
|
||||
$target = PhonebookManager::AVATARS_DIR . '/' . $filename;
|
||||
|
||||
if (!move_uploaded_file((string)$file['tmp_name'], $target)) {
|
||||
throw new RuntimeException("Impossible d'enregistrer la photo sur le serveur.");
|
||||
}
|
||||
@chmod($target, 0664);
|
||||
|
||||
$avatarUrl = "/avatars/{$filename}";
|
||||
$extId = self::getExtensionId($allowedExts, $ext);
|
||||
|
||||
$db->prepare('UPDATE phonebook_contacts SET photo_url = :url, updated_at = CURRENT_TIMESTAMP WHERE extension_id = :eid AND is_internal = 1')
|
||||
->execute([':url' => $avatarUrl, ':eid' => $extId]);
|
||||
|
||||
return $avatarUrl;
|
||||
}
|
||||
|
||||
public static function resolveAvatarPath(string $filename): ?string
|
||||
{
|
||||
$base = basename($filename);
|
||||
if (!preg_match('/^[a-zA-Z0-9._\-]+\.(jpg|jpeg|png|webp)$/i', $base)) {
|
||||
return null;
|
||||
}
|
||||
$full = PhonebookManager::AVATARS_DIR . '/' . $base;
|
||||
return is_file($full) && is_readable($full) ? $full : null;
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// HELPERS VALIDATION
|
||||
// =========================================================================
|
||||
|
||||
private static function assertAllowedExtension(array $allowedExts, string $ext): string
|
||||
{
|
||||
$clean = trim($ext);
|
||||
foreach ($allowedExts as $e) {
|
||||
if ((string)$e['extension'] === $clean) {
|
||||
return $clean;
|
||||
}
|
||||
}
|
||||
throw new InvalidArgumentException("Accès refusé à ce poste SIP ({$clean}).");
|
||||
}
|
||||
|
||||
private static function getExtensionId(array $allowedExts, string $ext): int
|
||||
{
|
||||
foreach ($allowedExts as $e) {
|
||||
if ((string)$e['extension'] === $ext) {
|
||||
return (int)$e['id'];
|
||||
}
|
||||
}
|
||||
throw new InvalidArgumentException("Poste introuvable.");
|
||||
}
|
||||
|
||||
private static function assertValidVmFolder(string $folder): string
|
||||
{
|
||||
if (!in_array($folder, ['INBOX', 'Old'], true)) {
|
||||
throw new InvalidArgumentException("Dossier messagerie invalide.");
|
||||
}
|
||||
return $folder;
|
||||
}
|
||||
|
||||
private static function assertValidMsgId(string $msgId): string
|
||||
{
|
||||
if (!preg_match('/^msg\d{4}$/', $msgId)) {
|
||||
throw new InvalidArgumentException("Identifiant de message vocal invalide.");
|
||||
}
|
||||
return $msgId;
|
||||
}
|
||||
}
|
||||
+342
@@ -0,0 +1,342 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/../src/Database.php';
|
||||
require_once __DIR__ . '/../src/FirewallManager.php';
|
||||
require_once __DIR__ . '/../src/AmiClient.php';
|
||||
require_once __DIR__ . '/../src/ConfigGenerator.php';
|
||||
require_once __DIR__ . '/../src/CdrManager.php';
|
||||
require_once __DIR__ . '/../src/PhonebookManager.php';
|
||||
require_once __DIR__ . '/../src/UcpAuth.php';
|
||||
require_once __DIR__ . '/../src/UcpManager.php';
|
||||
|
||||
use App\Database;
|
||||
use App\UcpAuth;
|
||||
use App\UcpManager;
|
||||
|
||||
$action = trim((string)($_GET['action'] ?? $_POST['action'] ?? ''));
|
||||
$isBinaryEndpoint = in_array($action, ['stream_voicemail', 'avatar'], true);
|
||||
|
||||
if (!$isBinaryEndpoint) {
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
}
|
||||
|
||||
try {
|
||||
$db = Database::getConnection();
|
||||
UcpAuth::startSession();
|
||||
|
||||
if (!UcpAuth::isUcpEnabled($db)) {
|
||||
http_response_code(503);
|
||||
echo json_encode(['success' => false, 'message' => 'Le portail utilisateur UCP est actuellement désactivé par l’administrateur.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Lecture du payload JSON si applicable
|
||||
$payload = $_POST;
|
||||
$contentType = (string)($_SERVER['CONTENT_TYPE'] ?? '');
|
||||
if (str_contains($contentType, 'application/json')) {
|
||||
$raw = file_get_contents('php://input');
|
||||
$decoded = json_decode((string)$raw, true);
|
||||
if (is_array($decoded)) {
|
||||
$payload = $decoded;
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 1. Authentification (routes publiques de session)
|
||||
// -------------------------------------------------------------------------
|
||||
if ($action === 'login') {
|
||||
$username = (string)($payload['username'] ?? '');
|
||||
$password = (string)($payload['password'] ?? '');
|
||||
|
||||
if (UcpAuth::login($db, $username, $password)) {
|
||||
$user = UcpAuth::getCurrentUser($db);
|
||||
echo json_encode([
|
||||
'success' => true,
|
||||
'username' => $user['username'] ?? $username,
|
||||
'csrf_token' => UcpAuth::getCsrfToken(),
|
||||
'message' => 'Connexion réussie.',
|
||||
]);
|
||||
} else {
|
||||
http_response_code(401);
|
||||
echo json_encode([
|
||||
'success' => false,
|
||||
'message' => 'Identifiant ou mot de passe incorrect.',
|
||||
]);
|
||||
}
|
||||
exit;
|
||||
}
|
||||
|
||||
if ($action === 'logout') {
|
||||
UcpAuth::logout();
|
||||
echo json_encode(['success' => true, 'message' => 'Déconnexion effectuée.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 2. Barrière d'authentification UCP obligatoire
|
||||
// -------------------------------------------------------------------------
|
||||
$currentUser = UcpAuth::getCurrentUser($db);
|
||||
if (!$currentUser) {
|
||||
http_response_code(401);
|
||||
echo json_encode(['success' => false, 'message' => 'Session expirée ou non authentifiée.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Protection CSRF sur toutes les actions modificatrices POST
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
$csrfHeader = (string)($_SERVER['HTTP_X_CSRF_TOKEN'] ?? $payload['csrf_token'] ?? '');
|
||||
if (!UcpAuth::verifyCsrfToken($csrfHeader)) {
|
||||
http_response_code(403);
|
||||
echo json_encode(['success' => false, 'message' => 'Jeton de sécurité CSRF invalide. Rechargez la page.']);
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
$ucpUserId = (int)$currentUser['id'];
|
||||
$allowedExts = UcpAuth::getAllowedExtensions($db, $ucpUserId);
|
||||
|
||||
switch ($action) {
|
||||
case 'get_state':
|
||||
$branding = $db->query('SELECT company_name, company_logo FROM settings WHERE id = 1 LIMIT 1')->fetch(PDO::FETCH_ASSOC) ?: [];
|
||||
echo json_encode([
|
||||
'success' => true,
|
||||
'data' => [
|
||||
'user' => [
|
||||
'id' => $ucpUserId,
|
||||
'username' => (string)$currentUser['username'],
|
||||
'display_name' => (string)$currentUser['display_name'],
|
||||
'email' => (string)$currentUser['email'],
|
||||
'last_login_at' => $currentUser['last_login_at'],
|
||||
],
|
||||
'branding' => [
|
||||
'company_name' => (string)($branding['company_name'] ?? ''),
|
||||
'company_logo' => (string)($branding['company_logo'] ?? ''),
|
||||
],
|
||||
'csrf_token' => UcpAuth::getCsrfToken(),
|
||||
'extensions' => UcpManager::getExtensionsState($db, $allowedExts),
|
||||
'forward_targets' => UcpManager::getForwardTargets($db),
|
||||
],
|
||||
]);
|
||||
break;
|
||||
|
||||
case 'save_call_features':
|
||||
$ext = (string)($payload['extension'] ?? '');
|
||||
UcpManager::setCallFeatures($db, $allowedExts, $ext, [
|
||||
'dnd' => !empty($payload['dnd']),
|
||||
'cf' => (string)($payload['cf'] ?? ''),
|
||||
'cfu' => (string)($payload['cfu'] ?? ''),
|
||||
]);
|
||||
echo json_encode([
|
||||
'success' => true,
|
||||
'extensions' => UcpManager::getExtensionsState($db, $allowedExts),
|
||||
'message' => "Paramètres d'appel du poste {$ext} appliqués en direct.",
|
||||
]);
|
||||
break;
|
||||
|
||||
case 'get_voicemails':
|
||||
$ext = (string)($_GET['extension'] ?? $payload['extension'] ?? '');
|
||||
if ($ext === '' && !empty($allowedExts)) {
|
||||
$ext = (string)$allowedExts[0]['extension'];
|
||||
}
|
||||
$messages = $ext !== '' ? UcpManager::getVoicemails($allowedExts, $ext) : [];
|
||||
echo json_encode([
|
||||
'success' => true,
|
||||
'messages' => $messages,
|
||||
]);
|
||||
break;
|
||||
|
||||
case 'stream_voicemail':
|
||||
$ext = (string)($_GET['extension'] ?? '');
|
||||
$folder = (string)($_GET['folder'] ?? '');
|
||||
$msgId = (string)($_GET['id'] ?? '');
|
||||
$download = !empty($_GET['download']);
|
||||
|
||||
$filePath = UcpManager::getVoicemailAudioPath($allowedExts, $ext, $folder, $msgId);
|
||||
$size = filesize($filePath);
|
||||
$fileExt = strtolower(pathinfo($filePath, PATHINFO_EXTENSION));
|
||||
$mime = ($fileExt === 'gsm') ? 'audio/gsm' : 'audio/wav';
|
||||
|
||||
while (ob_get_level()) {
|
||||
ob_end_clean();
|
||||
}
|
||||
|
||||
header("Content-Type: {$mime}");
|
||||
header('Accept-Ranges: bytes');
|
||||
header('Cache-Control: no-store, no-cache, must-revalidate');
|
||||
if ($download) {
|
||||
$dlName = "voicemail_{$ext}_{$msgId}.{$fileExt}";
|
||||
header("Content-Disposition: attachment; filename=\"{$dlName}\"");
|
||||
}
|
||||
|
||||
if (isset($_SERVER['HTTP_RANGE'])) {
|
||||
[$param, $range] = explode('=', $_SERVER['HTTP_RANGE'], 2);
|
||||
if (strtolower(trim($param)) === 'bytes') {
|
||||
[$from, $to] = explode('-', $range, 2);
|
||||
$from = intval($from);
|
||||
$to = ($to === '') ? ($size - 1) : intval($to);
|
||||
if ($from > $to || $from >= $size) {
|
||||
http_response_code(416);
|
||||
exit;
|
||||
}
|
||||
$length = $to - $from + 1;
|
||||
http_response_code(206);
|
||||
header("Content-Range: bytes {$from}-{$to}/{$size}");
|
||||
header("Content-Length: {$length}");
|
||||
$fp = fopen($filePath, 'rb');
|
||||
fseek($fp, $from);
|
||||
$remaining = $length;
|
||||
while ($remaining > 0 && !feof($fp)) {
|
||||
$chunk = min(8192, $remaining);
|
||||
echo fread($fp, $chunk);
|
||||
$remaining -= $chunk;
|
||||
flush();
|
||||
}
|
||||
fclose($fp);
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
header("Content-Length: {$size}");
|
||||
readfile($filePath);
|
||||
exit;
|
||||
|
||||
case 'toggle_voicemail_read':
|
||||
$ext = (string)($payload['extension'] ?? '');
|
||||
$folder = (string)($payload['folder'] ?? '');
|
||||
$msgId = (string)($payload['id'] ?? '');
|
||||
UcpManager::moveVoicemailFolder($db, $allowedExts, $ext, $folder, $msgId);
|
||||
echo json_encode([
|
||||
'success' => true,
|
||||
'messages' => UcpManager::getVoicemails($allowedExts, $ext),
|
||||
'extensions' => UcpManager::getExtensionsState($db, $allowedExts),
|
||||
'message' => ($folder === 'INBOX') ? 'Message marqué comme lu (archivé).' : 'Message marqué comme non lu.',
|
||||
]);
|
||||
break;
|
||||
|
||||
case 'delete_voicemail':
|
||||
$ext = (string)($payload['extension'] ?? '');
|
||||
$folder = (string)($payload['folder'] ?? '');
|
||||
$msgId = (string)($payload['id'] ?? '');
|
||||
UcpManager::deleteVoicemail($db, $allowedExts, $ext, $folder, $msgId);
|
||||
echo json_encode([
|
||||
'success' => true,
|
||||
'messages' => UcpManager::getVoicemails($allowedExts, $ext),
|
||||
'extensions' => UcpManager::getExtensionsState($db, $allowedExts),
|
||||
'message' => 'Message vocal supprimé définitivement.',
|
||||
]);
|
||||
break;
|
||||
|
||||
case 'save_voicemail_prefs':
|
||||
$ext = (string)($payload['extension'] ?? '');
|
||||
$pin = (string)($payload['pin'] ?? '');
|
||||
$email = (string)($payload['email'] ?? '');
|
||||
UcpManager::updateVoicemailPrefs($db, $allowedExts, $ext, $pin, $email);
|
||||
$freshAllowed = UcpAuth::getAllowedExtensions($db, $ucpUserId);
|
||||
echo json_encode([
|
||||
'success' => true,
|
||||
'extensions' => UcpManager::getExtensionsState($db, $freshAllowed),
|
||||
'message' => 'Préférences messagerie vocale enregistrées et appliquées.',
|
||||
]);
|
||||
break;
|
||||
|
||||
case 'get_personal_cdr':
|
||||
$data = UcpManager::getPersonalCdr($db, $allowedExts, [
|
||||
'direction' => $_GET['direction'] ?? 'all',
|
||||
'disposition' => $_GET['disposition'] ?? 'all',
|
||||
'period' => $_GET['period'] ?? 'all',
|
||||
'search' => $_GET['search'] ?? '',
|
||||
]);
|
||||
echo json_encode([
|
||||
'success' => true,
|
||||
'data' => $data,
|
||||
]);
|
||||
break;
|
||||
|
||||
case 'get_phonebook':
|
||||
echo json_encode([
|
||||
'success' => true,
|
||||
'contacts' => UcpManager::getPhonebookForUser($db, $allowedExts),
|
||||
]);
|
||||
break;
|
||||
|
||||
case 'save_my_email':
|
||||
$ext = (string)($payload['extension'] ?? '');
|
||||
$email = (string)($payload['email'] ?? '');
|
||||
UcpManager::updateMyContactEmail($db, $allowedExts, $ext, $email);
|
||||
echo json_encode([
|
||||
'success' => true,
|
||||
'contacts' => UcpManager::getPhonebookForUser($db, $allowedExts),
|
||||
'extensions' => UcpManager::getExtensionsState($db, $allowedExts),
|
||||
'message' => "E-mail annuaire du poste {$ext} mis à jour.",
|
||||
]);
|
||||
break;
|
||||
|
||||
case 'upload_my_avatar':
|
||||
$ext = (string)($_POST['extension'] ?? '');
|
||||
if (empty($_FILES['avatar'])) {
|
||||
throw new InvalidArgumentException("Aucun fichier image reçu.");
|
||||
}
|
||||
$avatarUrl = UcpManager::uploadMyAvatar($db, $allowedExts, $ext, $_FILES['avatar']);
|
||||
echo json_encode([
|
||||
'success' => true,
|
||||
'photo_url' => $avatarUrl,
|
||||
'contacts' => UcpManager::getPhonebookForUser($db, $allowedExts),
|
||||
'extensions' => UcpManager::getExtensionsState($db, $allowedExts),
|
||||
'message' => "Photo de profil du poste {$ext} mise à jour.",
|
||||
]);
|
||||
break;
|
||||
|
||||
case 'avatar':
|
||||
$file = (string)($_GET['file'] ?? '');
|
||||
$path = UcpManager::resolveAvatarPath($file);
|
||||
if (!$path) {
|
||||
http_response_code(404);
|
||||
exit;
|
||||
}
|
||||
$extName = strtolower(pathinfo($path, PATHINFO_EXTENSION));
|
||||
$mime = match ($extName) {
|
||||
'png' => 'image/png',
|
||||
'webp' => 'image/webp',
|
||||
default => 'image/jpeg',
|
||||
};
|
||||
while (ob_get_level()) {
|
||||
ob_end_clean();
|
||||
}
|
||||
header("Content-Type: {$mime}");
|
||||
header('Cache-Control: public, max-age=3600');
|
||||
readfile($path);
|
||||
exit;
|
||||
|
||||
case 'change_password':
|
||||
$cur = (string)($payload['current_password'] ?? '');
|
||||
$new = (string)($payload['new_password'] ?? '');
|
||||
UcpAuth::updateOwnPassword($db, $ucpUserId, $cur, $new);
|
||||
echo json_encode([
|
||||
'success' => true,
|
||||
'message' => 'Mot de passe UCP modifié avec succès.',
|
||||
]);
|
||||
break;
|
||||
|
||||
default:
|
||||
http_response_code(400);
|
||||
echo json_encode(['success' => false, 'message' => 'Action UCP inconnue.']);
|
||||
break;
|
||||
}
|
||||
} catch (InvalidArgumentException $e) {
|
||||
http_response_code(400);
|
||||
if ($isBinaryEndpoint) {
|
||||
header('Content-Type: text/plain; charset=utf-8');
|
||||
echo $e->getMessage();
|
||||
} else {
|
||||
echo json_encode(['success' => false, 'message' => $e->getMessage()]);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
http_response_code(500);
|
||||
if ($isBinaryEndpoint) {
|
||||
header('Content-Type: text/plain; charset=utf-8');
|
||||
echo 'Erreur serveur.';
|
||||
} else {
|
||||
echo json_encode(['success' => false, 'message' => $e->getMessage()]);
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 8.9 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 795 KiB |
@@ -0,0 +1,831 @@
|
||||
'use strict';
|
||||
|
||||
let ucpState = {
|
||||
user: null,
|
||||
extensions: [],
|
||||
forward_targets: [],
|
||||
voicemailsByExt: {},
|
||||
selectedVmExt: '',
|
||||
vmFolderFilter: 'all',
|
||||
phonebook: [],
|
||||
activeTab: 'extensions',
|
||||
};
|
||||
|
||||
let cdrTimer = null;
|
||||
let pollTimer = null;
|
||||
|
||||
function escapeHtml(str) {
|
||||
return String(str ?? '')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
function showUcpToast(message, type = 'info') {
|
||||
const container = document.getElementById('ucp-toast-container');
|
||||
if (!container) return;
|
||||
|
||||
const palette = {
|
||||
success: 'bg-emerald-50 border-emerald-200 text-emerald-800',
|
||||
error: 'bg-rose-50 border-rose-200 text-rose-800',
|
||||
info: 'bg-white border-slate-200 text-slate-800',
|
||||
}[type] || 'bg-white border-slate-200 text-slate-800';
|
||||
|
||||
const el = document.createElement('div');
|
||||
el.className = `pointer-events-auto rounded-xl border px-4 py-3 text-xs font-semibold shadow-lg transition-all duration-200 ${palette}`;
|
||||
el.textContent = message;
|
||||
container.appendChild(el);
|
||||
|
||||
setTimeout(() => {
|
||||
el.style.opacity = '0';
|
||||
setTimeout(() => el.remove(), 220);
|
||||
}, 3500);
|
||||
}
|
||||
|
||||
function resolveUcpAvatarSrc(photoUrl) {
|
||||
const raw = String(photoUrl || '').trim();
|
||||
if (!raw) return '';
|
||||
if (raw.startsWith('/avatars/')) {
|
||||
const file = raw.slice('/avatars/'.length);
|
||||
return `/api.php?action=avatar&file=${encodeURIComponent(file)}`;
|
||||
}
|
||||
return raw;
|
||||
}
|
||||
|
||||
async function ucpApiCall(action, method = 'GET', body = null) {
|
||||
const opts = {
|
||||
method,
|
||||
credentials: 'same-origin',
|
||||
headers: {},
|
||||
};
|
||||
let url = `/api.php?action=${encodeURIComponent(action)}`;
|
||||
|
||||
if (method === 'GET' && body && typeof body === 'object') {
|
||||
const params = new URLSearchParams(body);
|
||||
url += `&${params.toString()}`;
|
||||
} else if (method === 'POST') {
|
||||
opts.headers['X-CSRF-Token'] = window.UCP_CSRF_TOKEN || '';
|
||||
if (body instanceof FormData) {
|
||||
opts.body = body;
|
||||
} else {
|
||||
opts.headers['Content-Type'] = 'application/json';
|
||||
opts.body = JSON.stringify(body || {});
|
||||
}
|
||||
}
|
||||
|
||||
const res = await fetch(url, opts);
|
||||
if (res.status === 401 && action !== 'login') {
|
||||
window.location.reload();
|
||||
return null;
|
||||
}
|
||||
|
||||
const json = await res.json();
|
||||
if (!res.ok || !json.success) {
|
||||
throw new Error(json.message || 'Erreur lors de la requête.');
|
||||
}
|
||||
return json;
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// AUTHENTIFICATION
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
const loginForm = document.getElementById('ucp-login-form');
|
||||
if (loginForm) {
|
||||
loginForm.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const errBox = document.getElementById('ucp-login-error');
|
||||
const btn = document.getElementById('ucp-login-btn');
|
||||
if (errBox) errBox.classList.add('hidden');
|
||||
if (btn) btn.disabled = true;
|
||||
|
||||
try {
|
||||
const res = await ucpApiCall('login', 'POST', {
|
||||
username: loginForm.elements['username'].value.trim(),
|
||||
password: loginForm.elements['password'].value,
|
||||
});
|
||||
if (res && res.csrf_token) {
|
||||
window.UCP_CSRF_TOKEN = res.csrf_token;
|
||||
}
|
||||
window.location.reload();
|
||||
} catch (err) {
|
||||
if (errBox) {
|
||||
errBox.textContent = err.message;
|
||||
errBox.classList.remove('hidden');
|
||||
}
|
||||
} finally {
|
||||
if (btn) btn.disabled = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function ucpLogout() {
|
||||
try {
|
||||
await ucpApiCall('logout', 'POST', {});
|
||||
} catch (_) {}
|
||||
window.location.reload();
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// NAVIGATION & ÉTAT GLOBAL
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
function switchUcpTab(tabName) {
|
||||
ucpState.activeTab = tabName;
|
||||
|
||||
document.querySelectorAll('.ucp-panel').forEach(p => p.classList.add('hidden'));
|
||||
const activePanel = document.getElementById(`ucp-panel-${tabName}`);
|
||||
if (activePanel) activePanel.classList.remove('hidden');
|
||||
|
||||
document.querySelectorAll('.ucp-tab-btn').forEach(btn => {
|
||||
const isMe = btn.dataset.ucpTab === tabName;
|
||||
btn.className = isMe
|
||||
? 'ucp-tab-btn px-3.5 py-2 rounded-xl text-xs font-semibold transition flex items-center gap-2 bg-indigo-50 text-indigo-700'
|
||||
: 'ucp-tab-btn px-3.5 py-2 rounded-xl text-xs font-semibold transition flex items-center gap-2 text-slate-600 hover:bg-slate-100';
|
||||
});
|
||||
|
||||
if (tabName === 'voicemail') {
|
||||
loadVoicemailsForSelected();
|
||||
} else if (tabName === 'cdr') {
|
||||
loadPersonalCdr();
|
||||
} else if (tabName === 'phonebook') {
|
||||
loadPhonebook();
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshUcpState(manual = false) {
|
||||
try {
|
||||
const res = await ucpApiCall('get_state', 'GET');
|
||||
if (!res || !res.data) return;
|
||||
|
||||
ucpState.user = res.data.user;
|
||||
ucpState.extensions = res.data.extensions || [];
|
||||
ucpState.forward_targets = res.data.forward_targets || [];
|
||||
if (res.data.csrf_token) {
|
||||
window.UCP_CSRF_TOKEN = res.data.csrf_token;
|
||||
}
|
||||
|
||||
// Sélection par défaut du poste pour la messagerie vocale
|
||||
const vmExts = ucpState.extensions.filter(e => Number(e.voicemail_enabled) === 1);
|
||||
if (!ucpState.selectedVmExt || !vmExts.some(e => e.extension === ucpState.selectedVmExt)) {
|
||||
ucpState.selectedVmExt = vmExts.length ? vmExts[0].extension : (ucpState.extensions[0]?.extension || '');
|
||||
}
|
||||
|
||||
updateBadges();
|
||||
renderExtensionsGrid();
|
||||
renderVmExtPills();
|
||||
syncVmPrefsForm();
|
||||
|
||||
const accExts = document.getElementById('ucp-account-exts');
|
||||
if (accExts) {
|
||||
accExts.textContent = ucpState.extensions.map(e => `${e.extension} (${e.name})`).join(', ') || 'Aucun poste lié';
|
||||
}
|
||||
|
||||
if (manual) {
|
||||
showUcpToast('État actualisé.', 'info');
|
||||
if (ucpState.activeTab === 'voicemail') await loadVoicemailsForSelected();
|
||||
if (ucpState.activeTab === 'cdr') await loadPersonalCdr();
|
||||
if (ucpState.activeTab === 'phonebook') await loadPhonebook();
|
||||
}
|
||||
} catch (err) {
|
||||
if (manual) showUcpToast(err.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function updateBadges() {
|
||||
const extBadge = document.getElementById('ucp-badge-exts');
|
||||
if (extBadge) extBadge.textContent = String(ucpState.extensions.length);
|
||||
|
||||
const totalNewVm = ucpState.extensions.reduce((acc, e) => acc + Number(e.vm_inbox_count || 0), 0);
|
||||
const vmBadge = document.getElementById('ucp-badge-vm');
|
||||
if (vmBadge) {
|
||||
vmBadge.textContent = String(totalNewVm);
|
||||
vmBadge.className = totalNewVm > 0
|
||||
? 'px-1.5 py-0.5 rounded-full bg-rose-100 text-rose-700 text-[10px] font-bold'
|
||||
: 'px-1.5 py-0.5 rounded-full bg-slate-200 text-slate-700 text-[10px] font-bold';
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// 1. MES POSTES & REDIRECTIONS (DND / CF / CFU)
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
function buildForwardOptions(extObj, selectedValue) {
|
||||
const val = String(selectedValue || '');
|
||||
let html = `<option value="">— Désactivé —</option>`;
|
||||
|
||||
if (Number(extObj.voicemail_enabled) === 1) {
|
||||
const vmVal = `vm-${extObj.extension}`;
|
||||
html += `<option value="${escapeHtml(vmVal)}" ${val === vmVal ? 'selected' : ''}>📬 Ma messagerie vocale (${escapeHtml(extObj.extension)})</option>`;
|
||||
}
|
||||
|
||||
const others = (ucpState.forward_targets || []).filter(t => t.extension !== extObj.extension);
|
||||
if (others.length > 0) {
|
||||
html += `<optgroup label="Postes internes">`;
|
||||
others.forEach(t => {
|
||||
html += `<option value="${escapeHtml(t.extension)}" ${val === t.extension ? 'selected' : ''}>📱 ${escapeHtml(t.extension)} — ${escapeHtml(t.name)}</option>`;
|
||||
});
|
||||
html += `</optgroup>`;
|
||||
}
|
||||
|
||||
const otherVms = (ucpState.forward_targets || []).filter(t => t.extension !== extObj.extension && Number(t.voicemail_enabled) === 1);
|
||||
if (otherVms.length > 0) {
|
||||
html += `<optgroup label="Messageries vocales d'autres postes">`;
|
||||
otherVms.forEach(t => {
|
||||
const vVal = `vm-${t.extension}`;
|
||||
html += `<option value="${escapeHtml(vVal)}" ${val === vVal ? 'selected' : ''}>📬 Répondeur ${escapeHtml(t.extension)} — ${escapeHtml(t.name)}</option>`;
|
||||
});
|
||||
html += `</optgroup>`;
|
||||
}
|
||||
|
||||
return html;
|
||||
}
|
||||
|
||||
function renderExtensionsGrid() {
|
||||
const grid = document.getElementById('ucp-extensions-grid');
|
||||
if (!grid) return;
|
||||
|
||||
if (!ucpState.extensions.length) {
|
||||
grid.innerHTML = `
|
||||
<div class="ucp-card rounded-2xl p-8 text-center text-sm text-slate-500 col-span-full">
|
||||
Aucun poste SIP n'est encore lié à votre compte. Contactez votre administrateur.
|
||||
</div>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
grid.innerHTML = ucpState.extensions.map(ext => {
|
||||
const avatarSrc = resolveUcpAvatarSrc(ext.photo_url);
|
||||
const initials = (ext.name || ext.extension).slice(0, 2).toUpperCase();
|
||||
const statusPill = ext.online
|
||||
? `<span class="inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full bg-emerald-50 border border-emerald-200 text-emerald-700 text-[11px] font-semibold">
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-emerald-500"></span>
|
||||
En ligne ${ext.rtt_ms !== null ? `• ${ext.rtt_ms} ms` : ''}
|
||||
</span>`
|
||||
: `<span class="inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full bg-slate-100 border border-slate-200 text-slate-500 text-[11px] font-semibold">
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-slate-400"></span>
|
||||
Hors ligne
|
||||
</span>`;
|
||||
|
||||
const vmPill = Number(ext.voicemail_enabled) === 1
|
||||
? `<button type="button" onclick="openExtensionVoicemail('${escapeHtml(ext.extension)}')"
|
||||
class="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-[11px] font-semibold border transition ${ext.vm_inbox_count > 0 ? 'bg-rose-50 border-rose-200 text-rose-700 hover:bg-rose-100' : 'bg-slate-50 border-slate-200 text-slate-600 hover:bg-slate-100'}">
|
||||
📬 ${ext.vm_inbox_count} nouveau(x)
|
||||
</button>`
|
||||
: '';
|
||||
|
||||
return `
|
||||
<div class="ucp-card rounded-2xl p-5 space-y-4">
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div class="flex items-center gap-3 min-w-0">
|
||||
${avatarSrc
|
||||
? `<img src="${escapeHtml(avatarSrc)}" alt="" class="w-12 h-12 rounded-full object-cover border border-slate-200 shrink-0">`
|
||||
: `<div class="w-12 h-12 rounded-full bg-indigo-50 border border-indigo-100 text-indigo-700 font-bold flex items-center justify-center text-sm shrink-0">${escapeHtml(initials)}</div>`}
|
||||
<div class="min-w-0">
|
||||
<div class="flex items-center gap-2 flex-wrap">
|
||||
<span class="font-mono font-bold text-indigo-700 text-base">${escapeHtml(ext.extension)}</span>
|
||||
<span class="font-bold text-slate-900 text-sm truncate">${escapeHtml(ext.name)}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 mt-1 flex-wrap">
|
||||
${statusPill}
|
||||
${vmPill}
|
||||
</div>
|
||||
${ext.ip ? `<div class="text-[11px] text-slate-400 font-mono mt-1">${escapeHtml(ext.ip)} ${ext.user_agent ? '• ' + escapeHtml(ext.user_agent) : ''}</div>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Switch Ne Pas Déranger (DND) -->
|
||||
<div class="flex items-center justify-between p-3.5 rounded-xl border ${ext.dnd ? 'bg-rose-50/70 border-rose-200' : 'bg-slate-50 border-slate-200'} transition">
|
||||
<div>
|
||||
<div class="text-xs font-bold ${ext.dnd ? 'text-rose-800' : 'text-slate-800'}">Ne Pas Déranger (DND)</div>
|
||||
<div class="text-[11px] ${ext.dnd ? 'text-rose-600' : 'text-slate-500'}">
|
||||
${ext.dnd ? 'Activé — les appels entrants sont refusés / envoyés en messagerie' : 'Désactivé — le poste sonne normalement'}
|
||||
</div>
|
||||
</div>
|
||||
<label class="relative inline-flex items-center cursor-pointer shrink-0">
|
||||
<input type="checkbox" id="ucp-dnd-${escapeHtml(ext.extension)}" ${ext.dnd ? 'checked' : ''}
|
||||
onchange="saveExtensionFeatures('${escapeHtml(ext.extension)}')" class="sr-only peer">
|
||||
<div class="w-11 h-6 bg-slate-300 rounded-full peer peer-checked:bg-rose-600 after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:after:translate-x-full"></div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Redirections (CF & CFU) -->
|
||||
<form onsubmit="event.preventDefault(); saveExtensionFeatures('${escapeHtml(ext.extension)}');" class="space-y-3 pt-1">
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label class="block text-[11px] font-semibold text-slate-600 mb-1">Renvoi inconditionnel (CF)</label>
|
||||
<select id="ucp-cf-${escapeHtml(ext.extension)}"
|
||||
class="w-full px-3 py-2 rounded-xl bg-slate-50 border border-slate-300 text-xs text-slate-900 focus:bg-white focus:outline-none focus:border-indigo-500">
|
||||
${buildForwardOptions(ext, ext.cf)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-[11px] font-semibold text-slate-600 mb-1">Renvoi si non-réponse (CFU)</label>
|
||||
<select id="ucp-cfu-${escapeHtml(ext.extension)}"
|
||||
class="w-full px-3 py-2 rounded-xl bg-slate-50 border border-slate-300 text-xs text-slate-900 focus:bg-white focus:outline-none focus:border-indigo-500">
|
||||
${buildForwardOptions(ext, ext.cfu)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center justify-between pt-1">
|
||||
<span class="text-[11px] text-slate-400">Renvoi limité aux postes internes & répondeurs</span>
|
||||
<button type="submit"
|
||||
class="px-4 py-2 rounded-xl bg-indigo-600 hover:bg-indigo-700 text-white text-xs font-semibold transition">
|
||||
Appliquer les renvois
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
async function saveExtensionFeatures(extNum) {
|
||||
const dndEl = document.getElementById(`ucp-dnd-${extNum}`);
|
||||
const cfEl = document.getElementById(`ucp-cf-${extNum}`);
|
||||
const cfuEl = document.getElementById(`ucp-cfu-${extNum}`);
|
||||
|
||||
try {
|
||||
const res = await ucpApiCall('save_call_features', 'POST', {
|
||||
extension: extNum,
|
||||
dnd: dndEl ? dndEl.checked : false,
|
||||
cf: cfEl ? cfEl.value : '',
|
||||
cfu: cfuEl ? cfuEl.value : '',
|
||||
});
|
||||
if (res && res.extensions) {
|
||||
ucpState.extensions = res.extensions;
|
||||
renderExtensionsGrid();
|
||||
}
|
||||
showUcpToast(res.message || 'Enregistré.', 'success');
|
||||
} catch (err) {
|
||||
showUcpToast(err.message, 'error');
|
||||
refreshUcpState(false);
|
||||
}
|
||||
}
|
||||
|
||||
function openExtensionVoicemail(extNum) {
|
||||
ucpState.selectedVmExt = extNum;
|
||||
switchUcpTab('voicemail');
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// 2. MESSAGERIE VOCALE
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
function renderVmExtPills() {
|
||||
const container = document.getElementById('ucp-vm-ext-pills');
|
||||
if (!container) return;
|
||||
|
||||
const vmExts = ucpState.extensions.filter(e => Number(e.voicemail_enabled) === 1);
|
||||
if (!vmExts.length) {
|
||||
container.innerHTML = `<span class="text-xs text-slate-400 italic">Aucune boîte vocale activée sur vos postes</span>`;
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = vmExts.map(e => {
|
||||
const active = e.extension === ucpState.selectedVmExt;
|
||||
return `
|
||||
<button type="button" onclick="selectVmExtension('${escapeHtml(e.extension)}')"
|
||||
class="px-3 py-1.5 rounded-xl text-xs font-semibold border transition flex items-center gap-1.5 ${active ? 'bg-indigo-600 text-white border-indigo-600' : 'bg-white text-slate-700 border-slate-200 hover:bg-slate-50'}">
|
||||
<span class="font-mono">${escapeHtml(e.extension)}</span>
|
||||
<span>${escapeHtml(e.name)}</span>
|
||||
${e.vm_inbox_count > 0 ? `<span class="px-1.5 py-0.2 rounded-full text-[10px] font-bold ${active ? 'bg-white/20 text-white' : 'bg-rose-100 text-rose-700'}">${e.vm_inbox_count}</span>` : ''}
|
||||
</button>
|
||||
`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function selectVmExtension(extNum) {
|
||||
ucpState.selectedVmExt = extNum;
|
||||
renderVmExtPills();
|
||||
syncVmPrefsForm();
|
||||
loadVoicemailsForSelected();
|
||||
}
|
||||
|
||||
function syncVmPrefsForm() {
|
||||
const form = document.getElementById('ucp-vm-prefs-form');
|
||||
const sub = document.getElementById('ucp-vm-prefs-subtitle');
|
||||
if (!form) return;
|
||||
|
||||
const extObj = ucpState.extensions.find(e => e.extension === ucpState.selectedVmExt);
|
||||
if (!extObj || Number(extObj.voicemail_enabled) !== 1) {
|
||||
if (sub) sub.textContent = 'Aucune boîte vocale sélectionnée';
|
||||
form.classList.add('opacity-50', 'pointer-events-none');
|
||||
return;
|
||||
}
|
||||
|
||||
form.classList.remove('opacity-50', 'pointer-events-none');
|
||||
if (sub) sub.textContent = `Poste ${extObj.extension} — ${extObj.name}`;
|
||||
form.elements['pin'].value = '';
|
||||
form.elements['email'].value = extObj.voicemail_email || '';
|
||||
}
|
||||
|
||||
function setVmFolderFilter(folder) {
|
||||
ucpState.vmFolderFilter = folder;
|
||||
['all', 'INBOX', 'Old'].forEach(f => {
|
||||
const btn = document.getElementById(`vm-filter-${f}`);
|
||||
if (!btn) return;
|
||||
btn.className = f === folder
|
||||
? 'px-3 py-1.5 rounded-lg bg-indigo-50 text-indigo-700'
|
||||
: 'px-3 py-1.5 rounded-lg text-slate-600 hover:bg-slate-50';
|
||||
});
|
||||
renderVoicemailsList();
|
||||
}
|
||||
|
||||
async function loadVoicemailsForSelected() {
|
||||
const listEl = document.getElementById('ucp-vm-list');
|
||||
const extObj = ucpState.extensions.find(e => e.extension === ucpState.selectedVmExt);
|
||||
|
||||
if (!extObj || Number(extObj.voicemail_enabled) !== 1) {
|
||||
if (listEl) {
|
||||
listEl.innerHTML = `<div class="ucp-card rounded-2xl p-8 text-center text-sm text-slate-400">La messagerie vocale n'est activée sur aucun de vos postes liés.</div>`;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await ucpApiCall('get_voicemails', 'GET', { extension: extObj.extension });
|
||||
ucpState.voicemailsByExt[extObj.extension] = res.messages || [];
|
||||
renderVoicemailsList();
|
||||
} catch (err) {
|
||||
if (listEl) {
|
||||
listEl.innerHTML = `<div class="ucp-card rounded-2xl p-6 text-center text-xs text-rose-600">${escapeHtml(err.message)}</div>`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function formatDuration(sec) {
|
||||
const s = Math.max(0, Number(sec || 0));
|
||||
const m = Math.floor(s / 60);
|
||||
const r = s % 60;
|
||||
return `${m}:${String(r).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function renderVoicemailsList() {
|
||||
const listEl = document.getElementById('ucp-vm-list');
|
||||
if (!listEl) return;
|
||||
|
||||
const all = ucpState.voicemailsByExt[ucpState.selectedVmExt] || [];
|
||||
const filtered = ucpState.vmFolderFilter === 'all'
|
||||
? all
|
||||
: all.filter(m => m.folder === ucpState.vmFolderFilter);
|
||||
|
||||
if (!filtered.length) {
|
||||
listEl.innerHTML = `
|
||||
<div class="ucp-card rounded-2xl p-10 text-center text-sm text-slate-400">
|
||||
Aucun message vocal dans ce dossier.
|
||||
</div>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
listEl.innerHTML = filtered.map(msg => {
|
||||
const streamUrl = `/api.php?action=stream_voicemail&extension=${encodeURIComponent(msg.extension)}&folder=${encodeURIComponent(msg.folder)}&id=${encodeURIComponent(msg.id)}`;
|
||||
const dlUrl = `${streamUrl}&download=1`;
|
||||
const badge = msg.is_new
|
||||
? `<span class="px-2 py-0.5 rounded-full bg-indigo-100 text-indigo-700 text-[10px] font-bold uppercase">Nouveau</span>`
|
||||
: `<span class="px-2 py-0.5 rounded-full bg-slate-100 text-slate-600 text-[10px] font-semibold uppercase">Lu / Archivé</span>`;
|
||||
|
||||
return `
|
||||
<div class="ucp-card rounded-2xl p-4 space-y-3 ${msg.is_new ? 'border-l-4 border-l-indigo-500' : ''}">
|
||||
<div class="flex flex-wrap items-start justify-between gap-2">
|
||||
<div>
|
||||
<div class="flex items-center gap-2 flex-wrap">
|
||||
<span class="font-bold text-slate-900 text-sm">${escapeHtml(msg.callerid)}</span>
|
||||
${badge}
|
||||
</div>
|
||||
<div class="text-xs text-slate-500 mt-0.5">
|
||||
📅 ${escapeHtml(msg.date_human)} • ⏱️ ${formatDuration(msg.duration)}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5 flex-wrap">
|
||||
<a href="${dlUrl}" class="px-2.5 py-1.5 rounded-lg border border-slate-200 bg-white hover:bg-slate-50 text-[11px] font-semibold text-slate-700 transition">
|
||||
⬇️ Télécharger
|
||||
</a>
|
||||
<button type="button" onclick="toggleVmRead('${escapeHtml(msg.extension)}', '${escapeHtml(msg.folder)}', '${escapeHtml(msg.id)}')"
|
||||
class="px-2.5 py-1.5 rounded-lg border border-slate-200 bg-white hover:bg-slate-50 text-[11px] font-semibold text-slate-700 transition">
|
||||
${msg.is_new ? '✓ Marquer lu' : '↺ Marquer non lu'}
|
||||
</button>
|
||||
<button type="button" onclick="deleteVm('${escapeHtml(msg.extension)}', '${escapeHtml(msg.folder)}', '${escapeHtml(msg.id)}')"
|
||||
class="px-2.5 py-1.5 rounded-lg border border-rose-200 bg-rose-50 hover:bg-rose-100 text-[11px] font-semibold text-rose-700 transition">
|
||||
🗑️ Supprimer
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<audio controls preload="none" src="${streamUrl}" class="w-full h-9 rounded-lg"></audio>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
async function toggleVmRead(ext, folder, id) {
|
||||
try {
|
||||
const res = await ucpApiCall('toggle_voicemail_read', 'POST', { extension: ext, folder, id });
|
||||
ucpState.voicemailsByExt[ext] = res.messages || [];
|
||||
if (res.extensions) {
|
||||
ucpState.extensions = res.extensions;
|
||||
updateBadges();
|
||||
renderVmExtPills();
|
||||
renderExtensionsGrid();
|
||||
}
|
||||
renderVoicemailsList();
|
||||
showUcpToast(res.message, 'success');
|
||||
} catch (err) {
|
||||
showUcpToast(err.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteVm(ext, folder, id) {
|
||||
if (!confirm('Supprimer définitivement ce message vocal ?')) return;
|
||||
try {
|
||||
const res = await ucpApiCall('delete_voicemail', 'POST', { extension: ext, folder, id });
|
||||
ucpState.voicemailsByExt[ext] = res.messages || [];
|
||||
if (res.extensions) {
|
||||
ucpState.extensions = res.extensions;
|
||||
updateBadges();
|
||||
renderVmExtPills();
|
||||
renderExtensionsGrid();
|
||||
}
|
||||
renderVoicemailsList();
|
||||
showUcpToast(res.message, 'success');
|
||||
} catch (err) {
|
||||
showUcpToast(err.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
const vmPrefsForm = document.getElementById('ucp-vm-prefs-form');
|
||||
if (vmPrefsForm) {
|
||||
vmPrefsForm.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
if (!ucpState.selectedVmExt) return;
|
||||
const btn = document.getElementById('ucp-vm-prefs-btn');
|
||||
if (btn) btn.disabled = true;
|
||||
|
||||
try {
|
||||
const res = await ucpApiCall('save_voicemail_prefs', 'POST', {
|
||||
extension: ucpState.selectedVmExt,
|
||||
pin: vmPrefsForm.elements['pin'].value.trim(),
|
||||
email: vmPrefsForm.elements['email'].value.trim(),
|
||||
});
|
||||
if (res.extensions) {
|
||||
ucpState.extensions = res.extensions;
|
||||
syncVmPrefsForm();
|
||||
}
|
||||
showUcpToast(res.message, 'success');
|
||||
} catch (err) {
|
||||
showUcpToast(err.message, 'error');
|
||||
} finally {
|
||||
if (btn) btn.disabled = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// 3. JOURNAL D'APPELS PERSONNEL
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
function debounceLoadCdr() {
|
||||
clearTimeout(cdrTimer);
|
||||
cdrTimer = setTimeout(loadPersonalCdr, 300);
|
||||
}
|
||||
|
||||
async function loadPersonalCdr() {
|
||||
const tbody = document.getElementById('ucp-cdr-tbody');
|
||||
if (!tbody) return;
|
||||
|
||||
try {
|
||||
const res = await ucpApiCall('get_personal_cdr', 'GET', {
|
||||
direction: document.getElementById('ucp-cdr-direction')?.value || 'all',
|
||||
disposition: document.getElementById('ucp-cdr-disposition')?.value || 'all',
|
||||
period: document.getElementById('ucp-cdr-period')?.value || 'all',
|
||||
search: document.getElementById('ucp-cdr-search')?.value || '',
|
||||
});
|
||||
const data = res.data || {};
|
||||
const calls = data.calls || [];
|
||||
const stats = data.stats || { total: 0, answered: 0, missed: 0 };
|
||||
|
||||
document.getElementById('ucp-cdr-total').textContent = String(stats.total);
|
||||
document.getElementById('ucp-cdr-answered').textContent = String(stats.answered);
|
||||
document.getElementById('ucp-cdr-missed').textContent = String(stats.missed);
|
||||
|
||||
if (!calls.length) {
|
||||
tbody.innerHTML = `<tr><td colspan="6" class="py-8 text-center text-slate-400">Aucun appel correspondant.</td></tr>`;
|
||||
return;
|
||||
}
|
||||
|
||||
tbody.innerHTML = calls.map(c => {
|
||||
const dirMap = {
|
||||
inbound: '<span class="px-2 py-0.5 rounded-full bg-sky-50 text-sky-700 border border-sky-200 text-[10px] font-bold">↘ Entrant</span>',
|
||||
outbound: '<span class="px-2 py-0.5 rounded-full bg-indigo-50 text-indigo-700 border border-indigo-200 text-[10px] font-bold">↗ Sortant</span>',
|
||||
internal: '<span class="px-2 py-0.5 rounded-full bg-slate-100 text-slate-700 border border-slate-200 text-[10px] font-bold">⇄ Interne</span>',
|
||||
};
|
||||
const ok = c.disposition === 'ANSWERED';
|
||||
const stBadge = ok
|
||||
? '<span class="px-2 py-0.5 rounded-full bg-emerald-50 text-emerald-700 border border-emerald-200 text-[10px] font-bold">Répondu</span>'
|
||||
: '<span class="px-2 py-0.5 rounded-full bg-rose-50 text-rose-700 border border-rose-200 text-[10px] font-bold">Manqué</span>';
|
||||
|
||||
return `
|
||||
<tr class="hover:bg-slate-50/80 transition">
|
||||
<td class="py-3 px-4 text-slate-600 font-mono whitespace-nowrap">${escapeHtml(c.date_human || c.calldate || '')}</td>
|
||||
<td class="py-3 px-4">${dirMap[c.direction] || escapeHtml(c.direction)}</td>
|
||||
<td class="py-3 px-4 font-semibold text-slate-900">${escapeHtml(c.src_label || c.src || '')}</td>
|
||||
<td class="py-3 px-4 font-semibold text-slate-900">${escapeHtml(c.dst_label || c.dst || '')}</td>
|
||||
<td class="py-3 px-4 font-mono text-slate-600">${formatDuration(c.billsec || c.duration || 0)}</td>
|
||||
<td class="py-3 px-4">${stBadge}</td>
|
||||
</tr>
|
||||
`;
|
||||
}).join('');
|
||||
} catch (err) {
|
||||
tbody.innerHTML = `<tr><td colspan="6" class="py-6 text-center text-rose-600">${escapeHtml(err.message)}</td></tr>`;
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// 4. ANNUAIRE & PROFIL UTILISATEUR
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
async function loadPhonebook() {
|
||||
try {
|
||||
const res = await ucpApiCall('get_phonebook', 'GET');
|
||||
ucpState.phonebook = res.contacts || [];
|
||||
renderPhonebookGrid();
|
||||
} catch (err) {
|
||||
showUcpToast(err.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function renderPhonebookGrid() {
|
||||
const grid = document.getElementById('ucp-pb-grid');
|
||||
if (!grid) return;
|
||||
|
||||
const q = (document.getElementById('ucp-pb-search')?.value || '').trim().toLowerCase();
|
||||
const list = (ucpState.phonebook || []).filter(c => {
|
||||
if (!q) return true;
|
||||
return [c.display_name, c.internal_number, c.mobile_number, c.other_number, c.email, c.group_name]
|
||||
.some(v => String(v || '').toLowerCase().includes(q));
|
||||
});
|
||||
|
||||
if (!list.length) {
|
||||
grid.innerHTML = `<div class="ucp-card rounded-2xl p-8 text-center text-sm text-slate-400 col-span-full">Aucun contact trouvé.</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
grid.innerHTML = list.map(c => {
|
||||
const avatarSrc = resolveUcpAvatarSrc(c.photo_url);
|
||||
const initials = (c.display_name || '?').slice(0, 2).toUpperCase();
|
||||
const mineBadge = c.is_mine
|
||||
? `<span class="px-2 py-0.5 rounded-full bg-indigo-50 text-indigo-700 border border-indigo-200 text-[10px] font-bold">Mon poste</span>`
|
||||
: '';
|
||||
|
||||
return `
|
||||
<div class="ucp-card rounded-2xl p-4 flex flex-col justify-between gap-3 ${c.is_mine ? 'ring-1 ring-indigo-200' : ''}">
|
||||
<div class="flex items-start gap-3">
|
||||
${avatarSrc
|
||||
? `<img src="${escapeHtml(avatarSrc)}" alt="" class="w-12 h-12 rounded-full object-cover border border-slate-200 shrink-0">`
|
||||
: `<div class="w-12 h-12 rounded-full bg-slate-100 text-slate-600 font-bold flex items-center justify-center text-sm shrink-0">${escapeHtml(initials)}</div>`}
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex items-center gap-1.5 flex-wrap">
|
||||
<span class="font-bold text-slate-900 text-sm truncate">${escapeHtml(c.display_name)}</span>
|
||||
${mineBadge}
|
||||
</div>
|
||||
<div class="text-[11px] text-slate-400">${escapeHtml(c.group_name || 'Général')}</div>
|
||||
<div class="mt-2 space-y-1 text-xs text-slate-600">
|
||||
${c.internal_number ? `<div class="font-mono">📱 Interne : <strong class="text-slate-900">${escapeHtml(c.internal_number)}</strong></div>` : ''}
|
||||
${c.mobile_number ? `<div class="font-mono">📲 Mobile : ${escapeHtml(c.mobile_number)}</div>` : ''}
|
||||
${c.other_number ? `<div class="font-mono">☎️ Autre : ${escapeHtml(c.other_number)}</div>` : ''}
|
||||
${c.email ? `<div class="truncate">✉️ ${escapeHtml(c.email)}</div>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
${c.is_mine ? `
|
||||
<button type="button" onclick="openProfileModal('${escapeHtml(c.mine_extension)}')"
|
||||
class="w-full py-1.5 px-3 rounded-xl bg-indigo-50 hover:bg-indigo-100 text-indigo-700 text-xs font-semibold border border-indigo-200 transition">
|
||||
✏️ Modifier ma photo / mon e-mail
|
||||
</button>
|
||||
` : ''}
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function openProfileModal(extNum) {
|
||||
const contact = (ucpState.phonebook || []).find(c => c.is_mine && String(c.mine_extension) === String(extNum));
|
||||
if (!contact) return;
|
||||
|
||||
document.getElementById('ucp-profile-ext').value = extNum;
|
||||
document.getElementById('ucp-profile-modal-sub').textContent = `Poste ${extNum} — ${contact.display_name}`;
|
||||
document.getElementById('ucp-profile-email-input').value = contact.email || '';
|
||||
|
||||
updateProfileModalPreview(contact.photo_url, contact.display_name);
|
||||
document.getElementById('ucp-profile-modal').classList.remove('hidden');
|
||||
}
|
||||
|
||||
function updateProfileModalPreview(photoUrl, name) {
|
||||
const img = document.getElementById('ucp-profile-preview');
|
||||
const init = document.getElementById('ucp-profile-initials');
|
||||
const src = resolveUcpAvatarSrc(photoUrl);
|
||||
|
||||
if (src) {
|
||||
img.src = src;
|
||||
img.classList.remove('hidden');
|
||||
init.classList.add('hidden');
|
||||
} else {
|
||||
img.classList.add('hidden');
|
||||
init.textContent = (name || '?').slice(0, 2).toUpperCase();
|
||||
init.classList.remove('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
function closeProfileModal() {
|
||||
document.getElementById('ucp-profile-modal').classList.add('hidden');
|
||||
}
|
||||
|
||||
async function uploadSelectedAvatar() {
|
||||
const input = document.getElementById('ucp-avatar-input');
|
||||
const ext = document.getElementById('ucp-profile-ext').value;
|
||||
if (!input || !input.files || !input.files[0] || !ext) return;
|
||||
|
||||
const fd = new FormData();
|
||||
fd.append('extension', ext);
|
||||
fd.append('avatar', input.files[0]);
|
||||
|
||||
try {
|
||||
const res = await ucpApiCall('upload_my_avatar', 'POST', fd);
|
||||
if (res.contacts) ucpState.phonebook = res.contacts;
|
||||
if (res.extensions) ucpState.extensions = res.extensions;
|
||||
updateProfileModalPreview(res.photo_url, ext);
|
||||
renderPhonebookGrid();
|
||||
renderExtensionsGrid();
|
||||
showUcpToast(res.message, 'success');
|
||||
} catch (err) {
|
||||
showUcpToast(err.message, 'error');
|
||||
} finally {
|
||||
input.value = '';
|
||||
}
|
||||
}
|
||||
|
||||
const profileEmailForm = document.getElementById('ucp-profile-email-form');
|
||||
if (profileEmailForm) {
|
||||
profileEmailForm.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const ext = document.getElementById('ucp-profile-ext').value;
|
||||
const email = document.getElementById('ucp-profile-email-input').value.trim();
|
||||
|
||||
try {
|
||||
const res = await ucpApiCall('save_my_email', 'POST', { extension: ext, email });
|
||||
if (res.contacts) ucpState.phonebook = res.contacts;
|
||||
if (res.extensions) ucpState.extensions = res.extensions;
|
||||
renderPhonebookGrid();
|
||||
closeProfileModal();
|
||||
showUcpToast(res.message, 'success');
|
||||
} catch (err) {
|
||||
showUcpToast(err.message, 'error');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// 5. CHANGEMENT DE MOT DE PASSE UCP
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
const passwordForm = document.getElementById('ucp-password-form');
|
||||
if (passwordForm) {
|
||||
passwordForm.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const cur = passwordForm.elements['current_password'].value;
|
||||
const nw = passwordForm.elements['new_password'].value;
|
||||
const conf = passwordForm.elements['confirm_password'].value;
|
||||
|
||||
if (nw !== conf) {
|
||||
showUcpToast('Les deux nouveaux mots de passe ne correspondent pas.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await ucpApiCall('change_password', 'POST', {
|
||||
current_password: cur,
|
||||
new_password: nw,
|
||||
});
|
||||
passwordForm.reset();
|
||||
showUcpToast(res.message, 'success');
|
||||
} catch (err) {
|
||||
showUcpToast(err.message, 'error');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// INITIALISATION
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
if (window.UCP_IS_AUTH) {
|
||||
refreshUcpState(false);
|
||||
pollTimer = setInterval(() => {
|
||||
if (!document.hidden) {
|
||||
refreshUcpState(false);
|
||||
}
|
||||
}, 15000);
|
||||
}
|
||||
+414
@@ -0,0 +1,414 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/../src/Database.php';
|
||||
require_once __DIR__ . '/../src/FirewallManager.php';
|
||||
require_once __DIR__ . '/../src/UcpAuth.php';
|
||||
|
||||
use App\Database;
|
||||
use App\UcpAuth;
|
||||
|
||||
$db = Database::getConnection();
|
||||
$ucpEnabled = UcpAuth::isUcpEnabled($db);
|
||||
$currentUser = $ucpEnabled ? UcpAuth::getCurrentUser($db) : null;
|
||||
$isAuth = ($currentUser !== null);
|
||||
|
||||
$branding = $db->query('SELECT company_name, company_logo FROM settings WHERE id = 1 LIMIT 1')->fetch(\PDO::FETCH_ASSOC) ?: [];
|
||||
$companyName = trim((string)($branding['company_name'] ?? ''));
|
||||
$companyLogo = trim((string)($branding['company_logo'] ?? ''));
|
||||
$appVerData = file_exists(__DIR__ . '/../version.json') ? json_decode((string)file_get_contents(__DIR__ . '/../version.json'), true) : [];
|
||||
$appVersion = $appVerData['version'] ?? '2.5.0';
|
||||
$csrfToken = UcpAuth::getCsrfToken();
|
||||
?>
|
||||
<!DOCTYPE html>
|
||||
<html lang="fr" class="h-full bg-slate-50">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title><?= htmlspecialchars($companyName ?: 'NexusPBX') ?> — Portail Utilisateur (UCP)</title>
|
||||
<link rel="icon" type="image/png" href="/assets/favicon.png?v=<?= file_exists(__DIR__ . '/assets/favicon.png') ? filemtime(__DIR__ . '/assets/favicon.png') : 1 ?>">
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script>
|
||||
tailwind.config = {
|
||||
theme: {
|
||||
extend: {
|
||||
fontFamily: {
|
||||
sans: ['Inter', 'system-ui', '-apple-system', 'sans-serif'],
|
||||
mono: ['JetBrains Mono', 'Fira Code', 'monospace'],
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
<style>
|
||||
body { background-color: #f8fafc; color: #0f172a; }
|
||||
.ucp-card { background: #ffffff; border: 1px solid #e2e8f0; box-shadow: 0 1px 3px rgba(15, 23, 42, 0.04); }
|
||||
::-webkit-scrollbar { width: 7px; height: 7px; }
|
||||
::-webkit-scrollbar-track { background: #f1f5f9; }
|
||||
::-webkit-scrollbar-thumb { background: #cbd5e1; border-radius: 4px; }
|
||||
::-webkit-scrollbar-thumb:hover { background: #94a3b8; }
|
||||
</style>
|
||||
</head>
|
||||
<body class="min-h-full font-sans antialiased text-slate-800">
|
||||
|
||||
<!-- Toast Container -->
|
||||
<div id="ucp-toast-container" class="fixed top-4 right-4 z-50 flex flex-col gap-2 max-w-sm w-full pointer-events-none px-4 sm:px-0"></div>
|
||||
|
||||
<?php if (!$ucpEnabled): ?>
|
||||
<div class="min-h-screen flex items-center justify-center p-6">
|
||||
<div class="max-w-md w-full ucp-card rounded-2xl p-8 text-center space-y-3">
|
||||
<div class="w-12 h-12 mx-auto rounded-xl bg-amber-50 border border-amber-200 flex items-center justify-center text-amber-600 text-2xl">🔒</div>
|
||||
<h1 class="text-lg font-bold text-slate-900">Portail Utilisateur désactivé</h1>
|
||||
<p class="text-sm text-slate-500">Ce service a été temporairement désactivé par votre administrateur système.</p>
|
||||
</div>
|
||||
</div>
|
||||
<?php elseif (!$isAuth): ?>
|
||||
<!-- ÉCRAN DE CONNEXION UCP (THÈME CLAIR ÉPURÉ) -->
|
||||
<div class="min-h-screen flex flex-col items-center justify-center p-4 bg-gradient-to-br from-slate-50 via-blue-50/40 to-indigo-50/50">
|
||||
<div class="w-full max-w-md ucp-card rounded-3xl p-8 shadow-xl shadow-slate-200/60 border border-slate-200/80">
|
||||
<div class="flex flex-col items-center text-center mb-7">
|
||||
<?php if ($companyLogo !== ''): ?>
|
||||
<img src="<?= htmlspecialchars($companyLogo) ?>" alt="Logo" class="h-14 w-auto object-contain mb-3 rounded-xl">
|
||||
<?php else: ?>
|
||||
<img src="/assets/logo.png?v=1" alt="NexusPBX" class="h-14 w-auto object-contain mb-3">
|
||||
<?php endif; ?>
|
||||
<span class="inline-flex items-center gap-1.5 px-3 py-1 rounded-full bg-indigo-50 border border-indigo-100 text-indigo-700 text-[11px] font-semibold uppercase tracking-wider mb-2">
|
||||
Portail Utilisateur • UCP
|
||||
</span>
|
||||
<h1 class="text-xl font-bold text-slate-900"><?= htmlspecialchars($companyName ?: 'NexusPBX') ?></h1>
|
||||
<p class="text-xs text-slate-500 mt-1">Connectez-vous pour gérer vos postes, renvois, messagerie et annuaire</p>
|
||||
</div>
|
||||
|
||||
<form id="ucp-login-form" class="space-y-4">
|
||||
<div>
|
||||
<label for="ucp-login-user" class="block text-xs font-semibold text-slate-700 mb-1.5">Identifiant utilisateur</label>
|
||||
<input type="text" id="ucp-login-user" name="username" required autocomplete="username" autofocus
|
||||
placeholder="ex: fred ou fred@entreprise.fr"
|
||||
class="w-full px-3.5 py-2.5 rounded-xl bg-slate-50 border border-slate-300 text-sm text-slate-900 focus:bg-white focus:ring-2 focus:ring-indigo-500/30 focus:border-indigo-500 focus:outline-none transition">
|
||||
</div>
|
||||
<div>
|
||||
<label for="ucp-login-pass" class="block text-xs font-semibold text-slate-700 mb-1.5">Mot de passe</label>
|
||||
<input type="password" id="ucp-login-pass" name="password" required autocomplete="current-password"
|
||||
placeholder="••••••••"
|
||||
class="w-full px-3.5 py-2.5 rounded-xl bg-slate-50 border border-slate-300 text-sm text-slate-900 focus:bg-white focus:ring-2 focus:ring-indigo-500/30 focus:border-indigo-500 focus:outline-none transition">
|
||||
</div>
|
||||
<div id="ucp-login-error" class="hidden rounded-xl bg-rose-50 border border-rose-200 px-3.5 py-2.5 text-xs font-medium text-rose-700"></div>
|
||||
<button type="submit" id="ucp-login-btn"
|
||||
class="w-full py-2.5 px-4 rounded-xl bg-indigo-600 hover:bg-indigo-700 active:bg-indigo-800 text-white text-sm font-semibold shadow-sm shadow-indigo-500/20 transition">
|
||||
Se connecter au portail
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div class="mt-6 pt-5 border-t border-slate-100 text-center text-[11px] text-slate-400">
|
||||
Espace utilisateur isolé • Protégé par Fail2ban • NexusPBX v<?= htmlspecialchars($appVersion) ?>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<!-- INTERFACE PRINCIPALE UCP -->
|
||||
<div class="min-h-screen flex flex-col">
|
||||
<!-- Top Navbar -->
|
||||
<header class="sticky top-0 z-30 bg-white/95 backdrop-blur border-b border-slate-200">
|
||||
<div class="max-w-6xl mx-auto px-4 sm:px-6 h-16 flex items-center justify-between gap-4">
|
||||
<div class="flex items-center gap-3 min-w-0">
|
||||
<?php if ($companyLogo !== ''): ?>
|
||||
<img src="<?= htmlspecialchars($companyLogo) ?>" alt="Logo" class="h-9 w-auto rounded-lg object-contain shrink-0">
|
||||
<?php else: ?>
|
||||
<img src="/assets/logo.png?v=1" alt="NexusPBX" class="h-9 w-auto object-contain shrink-0">
|
||||
<?php endif; ?>
|
||||
<div class="min-w-0">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="font-bold text-slate-900 text-sm sm:text-base truncate"><?= htmlspecialchars($companyName ?: 'NexusPBX') ?></span>
|
||||
<span class="hidden sm:inline-flex px-2 py-0.5 rounded-full bg-indigo-50 text-indigo-700 border border-indigo-100 text-[10px] font-bold uppercase tracking-wide">UCP</span>
|
||||
</div>
|
||||
<p class="text-[11px] text-slate-500 truncate">Espace personnel de <strong class="text-slate-700"><?= htmlspecialchars((string)($currentUser['display_name'] ?: $currentUser['username'])) ?></strong></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2 shrink-0">
|
||||
<button type="button" onclick="refreshUcpState(true)" title="Actualiser"
|
||||
class="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-xl border border-slate-200 bg-white hover:bg-slate-50 text-xs font-semibold text-slate-600 transition">
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/></svg>
|
||||
<span class="hidden sm:inline">Actualiser</span>
|
||||
</button>
|
||||
<button type="button" onclick="ucpLogout()"
|
||||
class="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-xl border border-rose-200 bg-rose-50 hover:bg-rose-100 text-xs font-semibold text-rose-700 transition">
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"/></svg>
|
||||
<span>Déconnexion</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Navigation Onglets -->
|
||||
<div class="max-w-6xl mx-auto px-4 sm:px-6 border-t border-slate-100 overflow-x-auto">
|
||||
<nav class="flex items-center gap-1 py-2 whitespace-nowrap" id="ucp-nav">
|
||||
<button type="button" data-ucp-tab="extensions" onclick="switchUcpTab('extensions')"
|
||||
class="ucp-tab-btn px-3.5 py-2 rounded-xl text-xs font-semibold transition flex items-center gap-2 bg-indigo-50 text-indigo-700">
|
||||
<span>📱 Mes Postes & Renvois</span>
|
||||
<span id="ucp-badge-exts" class="px-1.5 py-0.5 rounded-full bg-indigo-100 text-indigo-700 text-[10px] font-bold">0</span>
|
||||
</button>
|
||||
<button type="button" data-ucp-tab="voicemail" onclick="switchUcpTab('voicemail')"
|
||||
class="ucp-tab-btn px-3.5 py-2 rounded-xl text-xs font-semibold transition flex items-center gap-2 text-slate-600 hover:bg-slate-100">
|
||||
<span>📬 Messagerie Vocale</span>
|
||||
<span id="ucp-badge-vm" class="px-1.5 py-0.5 rounded-full bg-slate-200 text-slate-700 text-[10px] font-bold">0</span>
|
||||
</button>
|
||||
<button type="button" data-ucp-tab="cdr" onclick="switchUcpTab('cdr')"
|
||||
class="ucp-tab-btn px-3.5 py-2 rounded-xl text-xs font-semibold transition flex items-center gap-2 text-slate-600 hover:bg-slate-100">
|
||||
<span>📞 Mes Appels</span>
|
||||
</button>
|
||||
<button type="button" data-ucp-tab="phonebook" onclick="switchUcpTab('phonebook')"
|
||||
class="ucp-tab-btn px-3.5 py-2 rounded-xl text-xs font-semibold transition flex items-center gap-2 text-slate-600 hover:bg-slate-100">
|
||||
<span>📖 Annuaire & Profil</span>
|
||||
</button>
|
||||
<button type="button" data-ucp-tab="account" onclick="switchUcpTab('account')"
|
||||
class="ucp-tab-btn px-3.5 py-2 rounded-xl text-xs font-semibold transition flex items-center gap-2 text-slate-600 hover:bg-slate-100">
|
||||
<span>🔐 Mon Compte</span>
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Contenu principal -->
|
||||
<main class="flex-1 max-w-6xl w-full mx-auto px-4 sm:px-6 py-6 space-y-6">
|
||||
|
||||
<!-- ONGLET 1 : MES POSTES & RENVOIS -->
|
||||
<section id="ucp-panel-extensions" class="ucp-panel space-y-5">
|
||||
<div class="flex flex-col sm:flex-row sm:items-center justify-between gap-3">
|
||||
<div>
|
||||
<h2 class="text-lg font-bold text-slate-900">Mes Postes & Redirections</h2>
|
||||
<p class="text-xs text-slate-500">Activez le mode Ne Pas Déranger (DND) ou configurez vos renvois vers un autre poste ou votre messagerie vocale.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div id="ucp-extensions-grid" class="grid grid-cols-1 lg:grid-cols-2 gap-5">
|
||||
<div class="ucp-card rounded-2xl p-8 text-center text-sm text-slate-400 col-span-full">Chargement de vos postes…</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ONGLET 2 : MESSAGERIE VOCALE -->
|
||||
<section id="ucp-panel-voicemail" class="ucp-panel hidden space-y-5">
|
||||
<div class="flex flex-col sm:flex-row sm:items-center justify-between gap-3">
|
||||
<div>
|
||||
<h2 class="text-lg font-bold text-slate-900">Messagerie Vocale</h2>
|
||||
<p class="text-xs text-slate-500">Écoutez, téléchargez, archivez ou supprimez vos messages vocaux et gérez votre code PIN.</p>
|
||||
</div>
|
||||
<div id="ucp-vm-ext-pills" class="flex flex-wrap gap-2"></div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
<!-- Liste des messages -->
|
||||
<div class="lg:col-span-2 space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="inline-flex rounded-xl bg-white border border-slate-200 p-1 text-xs font-semibold">
|
||||
<button type="button" onclick="setVmFolderFilter('all')" id="vm-filter-all" class="px-3 py-1.5 rounded-lg bg-indigo-50 text-indigo-700">Tous</button>
|
||||
<button type="button" onclick="setVmFolderFilter('INBOX')" id="vm-filter-INBOX" class="px-3 py-1.5 rounded-lg text-slate-600 hover:bg-slate-50">Nouveaux</button>
|
||||
<button type="button" onclick="setVmFolderFilter('Old')" id="vm-filter-Old" class="px-3 py-1.5 rounded-lg text-slate-600 hover:bg-slate-50">Archivés</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="ucp-vm-list" class="space-y-3">
|
||||
<div class="ucp-card rounded-2xl p-8 text-center text-sm text-slate-400">Chargement des messages…</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Préférences Boîte Vocale -->
|
||||
<div class="ucp-card rounded-2xl p-5 h-fit space-y-4">
|
||||
<div>
|
||||
<h3 class="text-sm font-bold text-slate-900">Préférences Boîte Vocale</h3>
|
||||
<p id="ucp-vm-prefs-subtitle" class="text-xs text-slate-500 mt-0.5">Poste sélectionné</p>
|
||||
</div>
|
||||
<form id="ucp-vm-prefs-form" class="space-y-3">
|
||||
<div>
|
||||
<label class="block text-xs font-semibold text-slate-700 mb-1">Nouveau code PIN (4 à 12 chiffres)</label>
|
||||
<input type="password" name="pin" inputmode="numeric" pattern="\d{4,12}" placeholder="Laisser vide pour conserver l'actuel"
|
||||
class="w-full px-3 py-2 rounded-xl bg-slate-50 border border-slate-300 text-xs text-slate-900 font-mono focus:bg-white focus:ring-2 focus:ring-indigo-500/30 focus:border-indigo-500 focus:outline-none">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-semibold text-slate-700 mb-1">E-mail de notification</label>
|
||||
<input type="email" name="email" placeholder="prenom@entreprise.fr"
|
||||
class="w-full px-3 py-2 rounded-xl bg-slate-50 border border-slate-300 text-xs text-slate-900 focus:bg-white focus:ring-2 focus:ring-indigo-500/30 focus:border-indigo-500 focus:outline-none">
|
||||
</div>
|
||||
<button type="submit" id="ucp-vm-prefs-btn"
|
||||
class="w-full py-2 px-4 rounded-xl bg-indigo-600 hover:bg-indigo-700 text-white text-xs font-semibold transition">
|
||||
Enregistrer les préférences
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ONGLET 3 : MES APPELS (MINI-CDR PERSONNEL) -->
|
||||
<section id="ucp-panel-cdr" class="ucp-panel hidden space-y-5">
|
||||
<div class="flex flex-col sm:flex-row sm:items-center justify-between gap-3">
|
||||
<div>
|
||||
<h2 class="text-lg font-bold text-slate-900">Mon Journal d'Appels</h2>
|
||||
<p class="text-xs text-slate-500">Historique filtré sur vos postes liés uniquement.</p>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-2 text-xs">
|
||||
<span class="px-3 py-1 rounded-full bg-white border border-slate-200 font-semibold text-slate-700">Total : <strong id="ucp-cdr-total">0</strong></span>
|
||||
<span class="px-3 py-1 rounded-full bg-emerald-50 border border-emerald-200 font-semibold text-emerald-700">Répondus : <strong id="ucp-cdr-answered">0</strong></span>
|
||||
<span class="px-3 py-1 rounded-full bg-rose-50 border border-rose-200 font-semibold text-rose-700">Manqués : <strong id="ucp-cdr-missed">0</strong></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="ucp-card rounded-2xl p-4 grid grid-cols-1 sm:grid-cols-4 gap-3">
|
||||
<select id="ucp-cdr-direction" onchange="loadPersonalCdr()" class="px-3 py-2 rounded-xl bg-slate-50 border border-slate-300 text-xs font-medium text-slate-800">
|
||||
<option value="all">Toutes directions</option>
|
||||
<option value="inbound">Appels entrants</option>
|
||||
<option value="outbound">Appels sortants</option>
|
||||
<option value="internal">Appels internes</option>
|
||||
</select>
|
||||
<select id="ucp-cdr-disposition" onchange="loadPersonalCdr()" class="px-3 py-2 rounded-xl bg-slate-50 border border-slate-300 text-xs font-medium text-slate-800">
|
||||
<option value="all">Tous statuts</option>
|
||||
<option value="ANSWERED">Répondus uniquement</option>
|
||||
<option value="MISSED">Manqués / Sans réponse</option>
|
||||
</select>
|
||||
<select id="ucp-cdr-period" onchange="loadPersonalCdr()" class="px-3 py-2 rounded-xl bg-slate-50 border border-slate-300 text-xs font-medium text-slate-800">
|
||||
<option value="all">Tout l'historique</option>
|
||||
<option value="today">Aujourd'hui</option>
|
||||
<option value="week">7 derniers jours</option>
|
||||
<option value="month">30 derniers jours</option>
|
||||
</select>
|
||||
<input type="text" id="ucp-cdr-search" oninput="debounceLoadCdr()" placeholder="Rechercher un numéro ou nom…"
|
||||
class="px-3 py-2 rounded-xl bg-slate-50 border border-slate-300 text-xs text-slate-900 focus:bg-white focus:outline-none focus:border-indigo-500">
|
||||
</div>
|
||||
|
||||
<div class="ucp-card rounded-2xl overflow-hidden">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-left text-xs">
|
||||
<thead class="bg-slate-50 border-b border-slate-200 text-slate-500 uppercase text-[11px]">
|
||||
<tr>
|
||||
<th class="py-3 px-4">Date & Heure</th>
|
||||
<th class="py-3 px-4">Direction</th>
|
||||
<th class="py-3 px-4">De</th>
|
||||
<th class="py-3 px-4">Vers</th>
|
||||
<th class="py-3 px-4">Durée</th>
|
||||
<th class="py-3 px-4">Statut</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="ucp-cdr-tbody" class="divide-y divide-slate-100">
|
||||
<tr><td colspan="6" class="py-8 text-center text-slate-400">Chargement…</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ONGLET 4 : ANNUAIRE & PROFIL -->
|
||||
<section id="ucp-panel-phonebook" class="ucp-panel hidden space-y-5">
|
||||
<div class="flex flex-col sm:flex-row sm:items-center justify-between gap-3">
|
||||
<div>
|
||||
<h2 class="text-lg font-bold text-slate-900">Annuaire de l'Entreprise</h2>
|
||||
<p class="text-xs text-slate-500">Consultez tous les contacts et mettez à jour la photo ou l'e-mail de vos propres postes.</p>
|
||||
</div>
|
||||
<input type="text" id="ucp-pb-search" oninput="renderPhonebookGrid()" placeholder="Rechercher un nom, poste, email…"
|
||||
class="w-full sm:w-72 px-3.5 py-2 rounded-xl bg-white border border-slate-300 text-xs text-slate-900 focus:ring-2 focus:ring-indigo-500/30 focus:border-indigo-500 focus:outline-none">
|
||||
</div>
|
||||
|
||||
<div id="ucp-pb-grid" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
<div class="ucp-card rounded-2xl p-8 text-center text-sm text-slate-400 col-span-full">Chargement de l'annuaire…</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ONGLET 5 : MON COMPTE -->
|
||||
<section id="ucp-panel-account" class="ucp-panel hidden space-y-5">
|
||||
<div>
|
||||
<h2 class="text-lg font-bold text-slate-900">Mon Compte UCP</h2>
|
||||
<p class="text-xs text-slate-500">Sécurisez votre accès personnel au portail utilisateur.</p>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div class="ucp-card rounded-2xl p-6 space-y-4">
|
||||
<h3 class="text-sm font-bold text-slate-900">Informations de session</h3>
|
||||
<dl class="space-y-2.5 text-xs">
|
||||
<div class="flex justify-between py-2 border-b border-slate-100">
|
||||
<dt class="text-slate-500">Identifiant</dt>
|
||||
<dd class="font-mono font-semibold text-slate-900"><?= htmlspecialchars((string)$currentUser['username']) ?></dd>
|
||||
</div>
|
||||
<div class="flex justify-between py-2 border-b border-slate-100">
|
||||
<dt class="text-slate-500">Nom affiché</dt>
|
||||
<dd class="font-semibold text-slate-900"><?= htmlspecialchars((string)($currentUser['display_name'] ?: $currentUser['username'])) ?></dd>
|
||||
</div>
|
||||
<div class="flex justify-between py-2 border-b border-slate-100">
|
||||
<dt class="text-slate-500">Postes autorisés</dt>
|
||||
<dd id="ucp-account-exts" class="font-mono font-semibold text-indigo-600">—</dd>
|
||||
</div>
|
||||
<div class="flex justify-between py-2">
|
||||
<dt class="text-slate-500">Dernière connexion</dt>
|
||||
<dd class="text-slate-700"><?= htmlspecialchars((string)($currentUser['last_login_at'] ?? 'Première connexion')) ?></dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<div class="ucp-card rounded-2xl p-6 space-y-4">
|
||||
<h3 class="text-sm font-bold text-slate-900">Changer mon mot de passe UCP</h3>
|
||||
<form id="ucp-password-form" class="space-y-3">
|
||||
<div>
|
||||
<label class="block text-xs font-semibold text-slate-700 mb-1">Mot de passe actuel</label>
|
||||
<input type="password" name="current_password" required autocomplete="current-password"
|
||||
class="w-full px-3 py-2 rounded-xl bg-slate-50 border border-slate-300 text-xs text-slate-900 focus:bg-white focus:outline-none focus:border-indigo-500">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-semibold text-slate-700 mb-1">Nouveau mot de passe (min. 6 caractères)</label>
|
||||
<input type="password" name="new_password" required minlength="6" autocomplete="new-password"
|
||||
class="w-full px-3 py-2 rounded-xl bg-slate-50 border border-slate-300 text-xs text-slate-900 focus:bg-white focus:outline-none focus:border-indigo-500">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-semibold text-slate-700 mb-1">Confirmer le nouveau mot de passe</label>
|
||||
<input type="password" name="confirm_password" required minlength="6" autocomplete="new-password"
|
||||
class="w-full px-3 py-2 rounded-xl bg-slate-50 border border-slate-300 text-xs text-slate-900 focus:bg-white focus:outline-none focus:border-indigo-500">
|
||||
</div>
|
||||
<button type="submit" class="w-full py-2.5 px-4 rounded-xl bg-indigo-600 hover:bg-indigo-700 text-white text-xs font-semibold transition">
|
||||
Mettre à jour mon mot de passe
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<!-- MODALE ÉDITION PROFIL ANNUAIRE (PHOTO + EMAIL UNIQUEMENT) -->
|
||||
<div id="ucp-profile-modal" class="hidden fixed inset-0 z-50 bg-slate-900/40 backdrop-blur-sm flex items-center justify-center p-4">
|
||||
<div class="w-full max-w-md bg-white rounded-2xl border border-slate-200 shadow-2xl p-6 space-y-5">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 class="text-base font-bold text-slate-900">Modifier ma fiche annuaire</h3>
|
||||
<p id="ucp-profile-modal-sub" class="text-xs text-slate-500">Poste —</p>
|
||||
</div>
|
||||
<button type="button" onclick="closeProfileModal()" class="text-slate-400 hover:text-slate-700 text-lg font-bold">✕</button>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-4 p-3.5 rounded-xl bg-slate-50 border border-slate-200">
|
||||
<img id="ucp-profile-preview" src="" alt="Avatar" class="w-14 h-14 rounded-full object-cover border border-slate-200 bg-white hidden">
|
||||
<div id="ucp-profile-initials" class="w-14 h-14 rounded-full bg-indigo-100 text-indigo-700 font-bold flex items-center justify-center text-lg">?</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<label class="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-white border border-slate-300 hover:bg-slate-100 text-xs font-semibold text-slate-700 cursor-pointer transition">
|
||||
📷 Choisir une photo
|
||||
<input type="file" id="ucp-avatar-input" accept="image/jpeg,image/png,image/webp" class="hidden" onchange="uploadSelectedAvatar()">
|
||||
</label>
|
||||
<p class="text-[11px] text-slate-400 mt-1">JPG, PNG ou WebP • 5 Mo max.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form id="ucp-profile-email-form" class="space-y-3">
|
||||
<input type="hidden" id="ucp-profile-ext" value="">
|
||||
<div>
|
||||
<label class="block text-xs font-semibold text-slate-700 mb-1">Adresse e-mail affichée dans l'annuaire</label>
|
||||
<input type="email" id="ucp-profile-email-input" placeholder="prenom.nom@entreprise.fr"
|
||||
class="w-full px-3.5 py-2 rounded-xl bg-slate-50 border border-slate-300 text-xs text-slate-900 focus:bg-white focus:outline-none focus:border-indigo-500">
|
||||
</div>
|
||||
<div class="flex gap-2 pt-2">
|
||||
<button type="button" onclick="closeProfileModal()" class="flex-1 py-2 px-4 rounded-xl border border-slate-200 text-xs font-semibold text-slate-600 hover:bg-slate-50">Fermer</button>
|
||||
<button type="submit" class="flex-1 py-2 px-4 rounded-xl bg-indigo-600 hover:bg-indigo-700 text-white text-xs font-semibold">Enregistrer l'e-mail</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<script>
|
||||
window.UCP_CSRF_TOKEN = <?= json_encode($csrfToken) ?>;
|
||||
window.UCP_IS_AUTH = <?= json_encode($isAuth) ?>;
|
||||
</script>
|
||||
<script src="/assets/ucp.js?v=<?= file_exists(__DIR__ . '/assets/ucp.js') ? filemtime(__DIR__ . '/assets/ucp.js') : time() ?>"></script>
|
||||
</body>
|
||||
</html>
|
||||
+10
-12
@@ -1,18 +1,16 @@
|
||||
{
|
||||
"version": "2.0.0",
|
||||
"codename": "Pulsar",
|
||||
"version": "2.5.0",
|
||||
"codename": "Pulsar UCP",
|
||||
"release_date": "2026-11-11",
|
||||
"min_php_version": "8.2",
|
||||
"changelog": [
|
||||
"NexusPBX 2.0 « Pulsar » : Version Majeure Haute Disponibilité Télécom & Résilience",
|
||||
"Haute Disponibilité (HA) : cluster actif/passif avec IP Virtuelle flottante (VIP) et consensus Keepalived VRRP",
|
||||
"HA Télécom : liaison stricte des transports PJSIP sur la VIP et annonce exclusive de la VIP en signalisation/média",
|
||||
"HA Télécom : isolation SIP stricte (iptables REJECT) sur nœud Standby et purge de subscription_persistence AstDB éliminant tout dialogue BLF zombie au failover",
|
||||
"HA Télécom : réveil BLF progressif automatique (check-sync) dès réenregistrement des postes sur le nouveau Master",
|
||||
"HA Données & Sécurité : réplication atomique SQLite (VACUUM INTO), synchro miroir voicemails & sons personnalisés (rsync --delete), propagation des IP bannies Fail2ban et réconciliation automatique au failback (--failback-check)",
|
||||
"Postes Hors Réseau & NAT/TLS : keepalive applicatif 30s (keep_alive_interval), TCP keepalives noyau (20s/5s/3), rtp_timeout, rtp_keepalive et SIP Session Timers (RFC 4028) sur tous les endpoints et trunks",
|
||||
"Routage & Groupes d'Appel : nouvelle destination directe Répondeur (vm-<poste>) en débordement, SVI et routes entrantes",
|
||||
"Sécurité & Téléphonie : détection temps réel des conversations actives avant redémarrage de poste avec confirmation sécurisée",
|
||||
"Paramètres SIP : fiabilisation du rtp_timeout_hold et expiration AOR plafonnée (120s-300s) pour maintenir les tunnels NAT actifs"
|
||||
"NexusPBX 2.5.0 « Pulsar UCP » (Issue #4) : Portail Utilisateur Isolé (User Control Panel)",
|
||||
"Sécurité & Isolation Nginx : VirtualHosts UCP dédiés (/var/www/nexus-pbx/ucp) séparés physiquement de l'Administration",
|
||||
"Ports UCP configurables depuis l'interface Pare-feu : HTTPS (8444) pour accès direct et HTTP (8084) pour Reverse Proxy, avec interrupteur Activer/Désactiver",
|
||||
"Fail2ban dédié Portail Utilisateur : nouvelle jail [nexus-ucp], filtre nexus-ucp.conf et journal isolé /var/log/nexus_ucp_security.log (résolution X-Real-IP derrière Proxy)",
|
||||
"Gestion Admin des Comptes UCP : création/édition/désactivation d'utilisateurs avec liaison d'une ou plusieurs extensions SIP",
|
||||
"Portail Employé Mode Clair : suivi des postes temps réel, activation NPD (DND) + mise à jour BLF, renvois internes (CF/CFU) et vers répondeur (vm-<poste>) sans numéro externe",
|
||||
"Messagerie Vocale Web : écoute HTML5 (Accept-Ranges), téléchargement WAV, marquage Lu/Non lu (synchro MWI), suppression et modification PIN/email",
|
||||
"Journal d'appels personnel filtré par extensions liées, annuaire complet avec édition photo (avatar) et email pour ses propres postes, et synchro HA des avatars"
|
||||
]
|
||||
}
|
||||
Reference in new issue
Block a user