Compare commits

..
24 Commits
Author SHA1 Message Date
sorcier d49520aecb Merge branch 'dev' 2026-11-12 10:51:48 +01:00
sorcier ddbaabb657 v2.6.7: Replace Admin & UCP emojis with uniform Heroicons SVG icons 2026-11-12 10:25:40 +01:00
sorcier 70a021ef63 ui(admin): v2.6.6 selecteur de destinations personnalise avec icones SVG style menu 2026-11-12 10:15:24 +01:00
sorcier b8cf932651 ui(admin): suppression des emojis dans les listes de destinations audio 2026-11-12 10:12:34 +01:00
sorcier 871a90c102 ui(admin): v2.6.5 intitules professionnels pour musiques d'attente et annonces en boucle 2026-11-12 10:10:29 +01:00
sorcier f94299def1 ui(ucp): placeholder generique en francais sur la connexion UCP 2026-11-12 10:00:03 +01:00
sorcier d9f1470c64 ui(ucp): nouveau wallpaper clair dedie a la page de connexion UCP 2026-11-12 09:55:32 +01:00
sorcier 5fddf3f275 ui(ucp): eclaircissement lumineux du wallpaper de connexion UCP 2026-11-12 09:49:07 +01:00
sorcier b702e369a8 ui(ucp): fond d'écran wallpaper sur la page de connexion UCP 2026-11-12 09:47:25 +01:00
sorcier c980aa2904 chore(update): verification et installation auto des extensions PHP requises lors des mises a jour 2026-11-12 09:42:57 +01:00
sorcier f918c929f5 fix(phonebook,ucp): v2.6.4 affichage des postes multi-groupes dans chaque onglet de groupe 2026-11-12 02:45:13 +01:00
sorcier 8cb8f1cdf7 ui(ucp): v2.6.3 onglets dynamiques par groupe dans l'Annuaire de l'Entreprise 2026-11-12 02:42:13 +01:00
sorcier 745e9307d7 fix(ucp): v2.6.2 regle stricte host/port GroundWire (NAT > VIP HA > IP locale + port local) 2026-11-12 02:35:46 +01:00
sorcier 789ea79cf2 style(extensions,ucp): v2.6.1 retrait icone smartphone GroundWire et boutons d'action uniformes 2026-11-12 02:09:37 +01:00
sorcier 28efb2d6bc feat(ucp): v2.6.0 provisionnement XML Acrobits GroundWire par poste 2026-11-12 02:04:47 +01:00
sorcier 135a6267db ui(ucp): cartes journal appels mobile style iPhone (v2.5.7) 2026-11-11 23:21:23 +01:00
sorcier 26cac163d6 ui(ucp): en-tête compact mobile et dégradé latéral onglets (v2.5.6) 2026-11-11 23:13:37 +01:00
sorcier d8ec7e4508 fix(ucp): adaptation iPhone/iOS Safari sans auto-zoom ni débordement (v2.5.5) 2026-11-11 23:04:04 +01:00
sorcier c10c980024 fix(ucp): correction journal personnel Mes Appels CDR (v2.5.4) 2026-11-11 22:44:17 +01:00
sorcier d618d426e7 feat(ucp): onglet Notice & Codes de fonction avec fiche PDF imprimable (v2.5.3) 2026-11-11 19:57:49 +01:00
sorcier 2a7bb2a122 chore(update): garantir ACLs nginx/fail2ban lors des mises à jour 2026-11-11 19:54:17 +01:00
sorcier 8df103d7a0 fix(ha): try/finally & purge temp /tmp cluster (v2.5.2) 2026-11-11 19:48:26 +01:00
sorcier c96af67ac9 fix(ucp,ha): auto-déploiement UCP, check SQLite PDO/SQLite3::backup HA, refresh comptes UCP (v2.5.1) 2026-11-11 19:31:32 +01:00
sorcier 72e43f1a9a feat(ucp): NexusPBX v2.5.0 Portail Utilisateur isolé (Refs #4) 2026-11-11 18:47:25 +01:00
21 changed files with 5121 additions and 333 deletions

No files matched your search

+22 -10
View File
@@ -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 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 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 touch /var/log/nexus_security.log /var/log/nexus_ucp_security.log
chown www-data:asterisk /var/log/nexus_security.log 2>/dev/null || true 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 2>/dev/null || true chmod 0664 /var/log/nexus_security.log /var/log/nexus_ucp_security.log 2>/dev/null || true
# Droits applicatifs stricts # Droits applicatifs stricts
chown -R www-data:asterisk "$TARGET_DIR" chown -R www-data:asterisk "$TARGET_DIR"
@@ -195,6 +195,7 @@ chmod 2775 "$TARGET_DIR/storage" \
"$TARGET_DIR/storage/system_backups" \ "$TARGET_DIR/storage/system_backups" \
"$TARGET_DIR/storage/sounds" \ "$TARGET_DIR/storage/sounds" \
"$TARGET_DIR/public/avatars" "$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 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 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) # Droits sudoers pour l'interface web (www-data)
cat << 'EOF' > /etc/sudoers.d/nexus-pbx cat << 'EOF' > /etc/sudoers.d/nexus-pbx
# Privilèges spécifiques NexusPBX pour l'utilisateur Web www-data # 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 EOF
chmod 0440 /etc/sudoers.d/nexus-pbx 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 rm -f /etc/nginx/sites-enabled/default
ln -sf /etc/nginx/sites-available/nexus-pbx /etc/nginx/sites-enabled/nexus-pbx 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 ' php -r '
require_once "/var/www/nexus-pbx/src/Database.php"; require_once "/var/www/nexus-pbx/src/Database.php";
require_once "/var/www/nexus-pbx/src/FirewallManager.php"; require_once "/var/www/nexus-pbx/src/FirewallManager.php";
$db = App\Database::getConnection(); $st = App\FirewallManager::getSettings();
$st = $db->query("SELECT admin_https_port, certbot_strict_port80 FROM settings WHERE id = 1")->fetch(PDO::FETCH_ASSOC); App\FirewallManager::ensureUcpFail2banArtifacts();
App\FirewallManager::applyNginxConfig((int)($st["admin_https_port"] ?? 8443), (int)($st["certbot_strict_port80"] ?? 0)); 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 cat << 'EOF' > /etc/fail2ban/filter.d/nexus-web.conf
[Definition] [Definition]
failregex = ^.*\[NEXUS-WEB-AUTH-FAIL\]:\s*<HOST>.*$ failregex = ^.*\[NEXUS-WEB-AUTH-FAIL\]:\s*<HOST>.*$
@@ -465,7 +472,12 @@ App\FirewallManager::generateJailLocal(
(int)$st["fail2ban_sip_bantime"], (int)$st["fail2ban_sip_bantime"],
(int)$st["fail2ban_web_maxretry"], (int)$st["fail2ban_web_maxretry"],
(int)$st["fail2ban_web_bantime"], (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)
); );
' '
+60 -8
View File
@@ -13,6 +13,7 @@ require_once __DIR__ . '/../src/FirewallManager.php';
require_once __DIR__ . '/../src/PhonebookManager.php'; require_once __DIR__ . '/../src/PhonebookManager.php';
require_once __DIR__ . '/../src/CdrManager.php'; require_once __DIR__ . '/../src/CdrManager.php';
require_once __DIR__ . '/../src/ClusterManager.php'; require_once __DIR__ . '/../src/ClusterManager.php';
require_once __DIR__ . '/../src/UcpManager.php';
// Autoloader PSR-4 automatique pour toutes les classes du namespace App // Autoloader PSR-4 automatique pour toutes les classes du namespace App
spl_autoload_register(function (string $class): void { spl_autoload_register(function (string $class): void {
@@ -36,6 +37,7 @@ use App\FirewallManager;
use App\PhonebookManager; use App\PhonebookManager;
use App\CdrManager; use App\CdrManager;
use App\ClusterManager; use App\ClusterManager;
use App\UcpManager;
header('Content-Type: application/json; charset=utf-8'); header('Content-Type: application/json; charset=utf-8');
header('Cache-Control: no-store, no-cache, must-revalidate'); header('Cache-Control: no-store, no-cache, must-revalidate');
@@ -96,10 +98,7 @@ try {
]); ]);
} else { } else {
// Journalisation de sécurité pour Fail2ban (jail nexus-web) // Journalisation de sécurité pour Fail2ban (jail nexus-web)
$clientIp = $_SERVER['HTTP_X_FORWARDED_FOR'] ?? $_SERVER['REMOTE_ADDR'] ?? '127.0.0.1'; $clientIp = FirewallManager::resolveClientIp();
if (str_contains($clientIp, ',')) {
$clientIp = trim(explode(',', $clientIp)[0]);
}
$logMsg = date('[Y-m-d H:i:s] ') . "[NEXUS-WEB-AUTH-FAIL]: " . $clientIp . " (username: " . ($username ?: 'vide') . ")\n"; $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); @file_put_contents('/var/log/nexus_security.log', $logMsg, FILE_APPEND | LOCK_EX);
@@ -134,6 +133,9 @@ try {
switch ($action) { switch ($action) {
case 'get_state': case 'get_state':
try {
FirewallManager::ensureUcpDeployed();
} catch (\Throwable) {}
$settings = $db->query('SELECT * FROM settings WHERE id = 1')->fetch(); $settings = $db->query('SELECT * FROM settings WHERE id = 1')->fetch();
$extensions = $db->query('SELECT * FROM extensions ORDER BY CAST(extension AS INTEGER) ASC, extension ASC')->fetchAll(); $extensions = $db->query('SELECT * FROM extensions ORDER BY CAST(extension AS INTEGER) ASC, extension ASC')->fetchAll();
$groups = $db->query('SELECT * FROM groups ORDER BY CAST(extension AS INTEGER) ASC, extension ASC')->fetchAll(); $groups = $db->query('SELECT * FROM groups ORDER BY CAST(extension AS INTEGER) ASC, extension ASC')->fetchAll();
@@ -210,6 +212,7 @@ try {
'backups_count' => count(BackupManager::listBackups($db)), 'backups_count' => count(BackupManager::listBackups($db)),
'phonebook_contacts' => PhonebookManager::getContacts($db), 'phonebook_contacts' => PhonebookManager::getContacts($db),
'phonebook_groups' => PhonebookManager::getGroups($db), 'phonebook_groups' => PhonebookManager::getGroups($db),
'ucp_users' => UcpManager::listUsers($db),
'metrics' => SystemMetrics::collect($db), 'metrics' => SystemMetrics::collect($db),
'previews' => [ 'previews' => [
'pjsip' => $generator->generatePjsipConf(), 'pjsip' => $generator->generatePjsipConf(),
@@ -273,6 +276,13 @@ try {
@chgrp($webLog, 'asterisk'); @chgrp($webLog, 'asterisk');
@chmod($webLog, 0664); @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'); @unlink('/tmp/nexus_fw_status_cache.json');
exec('sudo /usr/bin/systemctl restart fail2ban 2>&1 || sudo /usr/bin/fail2ban-client reload 2>&1'); exec('sudo /usr/bin/systemctl restart fail2ban 2>&1 || sudo /usr/bin/fail2ban-client reload 2>&1');
@@ -301,6 +311,7 @@ try {
$voicemail_attach = isset($payload['voicemail_attach']) ? (int)!empty($payload['voicemail_attach']) : 1; $voicemail_attach = isset($payload['voicemail_attach']) ? (int)!empty($payload['voicemail_attach']) : 1;
$voicemail_delete = isset($payload['voicemail_delete']) ? (int)!empty($payload['voicemail_delete']) : 0; $voicemail_delete = isset($payload['voicemail_delete']) ? (int)!empty($payload['voicemail_delete']) : 0;
$ring_time = max(5, min(120, (int)($payload['ring_time'] ?? 20))); $ring_time = max(5, min(120, (int)($payload['ring_time'] ?? 20)));
$groundwire_enabled = !empty($payload['groundwire_enabled']) ? 1 : 0;
if ($force_pin_change === 1) { if ($force_pin_change === 1) {
$secFile = "/var/spool/asterisk/voicemail/default/{$extension}/secret.conf"; $secFile = "/var/spool/asterisk/voicemail/default/{$extension}/secret.conf";
@@ -329,7 +340,7 @@ try {
encryption = :encryption, codecs = :codecs, max_contacts = :max_contacts, enabled = :enabled, encryption = :encryption, codecs = :codecs, max_contacts = :max_contacts, enabled = :enabled,
voicemail_enabled = :vme, voicemail_pin = :vmp, force_pin_change = :fpc, voicemail_enabled = :vme, voicemail_pin = :vmp, force_pin_change = :fpc,
voicemail_skip_pin = :vsp, voicemail_email = :vme_mail, voicemail_attach = :vma, voicemail_skip_pin = :vsp, voicemail_email = :vme_mail, voicemail_attach = :vma,
voicemail_delete = :vmd, ring_time = :rt voicemail_delete = :vmd, ring_time = :rt, groundwire_enabled = :gwe
WHERE id = :id WHERE id = :id
'); ');
$stmt->execute([ $stmt->execute([
@@ -350,14 +361,15 @@ try {
':vma' => $voicemail_attach, ':vma' => $voicemail_attach,
':vmd' => $voicemail_delete, ':vmd' => $voicemail_delete,
':rt' => $ring_time, ':rt' => $ring_time,
':gwe' => $groundwire_enabled,
]); ]);
} else { } else {
$stmt = $db->prepare(' $stmt = $db->prepare('
INSERT INTO extensions (extension, name, secret, transport, encryption, codecs, max_contacts, enabled, INSERT INTO extensions (extension, name, secret, transport, encryption, codecs, max_contacts, enabled,
voicemail_enabled, voicemail_pin, force_pin_change, voicemail_skip_pin, voicemail_enabled, voicemail_pin, force_pin_change, voicemail_skip_pin,
voicemail_email, voicemail_attach, voicemail_delete, ring_time) voicemail_email, voicemail_attach, voicemail_delete, ring_time, groundwire_enabled)
VALUES (:ext, :name, :secret, :transport, :encryption, :codecs, :max_contacts, :enabled, VALUES (:ext, :name, :secret, :transport, :encryption, :codecs, :max_contacts, :enabled,
:vme, :vmp, :fpc, :vsp, :vme_mail, :vma, :vmd, :rt) :vme, :vmp, :fpc, :vsp, :vme_mail, :vma, :vmd, :rt, :gwe)
ON CONFLICT(extension) DO UPDATE SET ON CONFLICT(extension) DO UPDATE SET
name = excluded.name, name = excluded.name,
secret = excluded.secret, secret = excluded.secret,
@@ -373,7 +385,8 @@ try {
voicemail_email = excluded.voicemail_email, voicemail_email = excluded.voicemail_email,
voicemail_attach = excluded.voicemail_attach, voicemail_attach = excluded.voicemail_attach,
voicemail_delete = excluded.voicemail_delete, voicemail_delete = excluded.voicemail_delete,
ring_time = excluded.ring_time ring_time = excluded.ring_time,
groundwire_enabled = excluded.groundwire_enabled
'); ');
$stmt->execute([ $stmt->execute([
':ext' => $extension, ':ext' => $extension,
@@ -392,6 +405,7 @@ try {
':vma' => $voicemail_attach, ':vma' => $voicemail_attach,
':vmd' => $voicemail_delete, ':vmd' => $voicemail_delete,
':rt' => $ring_time, ':rt' => $ring_time,
':gwe' => $groundwire_enabled,
]); ]);
} }
@@ -1913,6 +1927,11 @@ try {
$ami = AmiClient::fromDatabase($db); $ami = AmiClient::fromDatabase($db);
$reloadInfo = $ami->reloadPjsipAndDialplan(); $reloadInfo = $ami->reloadPjsipAndDialplan();
// S'assurer que le VHost UCP & Fail2ban UCP sont déployés même après un git pull manuel
try {
FirewallManager::ensureUcpDeployed();
} catch (\Throwable) {}
// Actualisation de l'horodatage de modification en base pour la réconciliation cluster // Actualisation de l'horodatage de modification en base pour la réconciliation cluster
try { try {
$db->exec("UPDATE cluster_settings SET updated_at = CURRENT_TIMESTAMP WHERE id = 1"); $db->exec("UPDATE cluster_settings SET updated_at = CURRENT_TIMESTAMP WHERE id = 1");
@@ -2446,6 +2465,39 @@ try {
echo json_encode($res); echo json_encode($res);
break; break;
case 'get_ucp_users':
try {
FirewallManager::ensureUcpDeployed();
} catch (\Throwable) {}
$users = UcpManager::listUsers($db);
echo json_encode([
'success' => true,
'users' => $users,
'ucp_users' => $users,
]);
break;
case 'save_ucp_user':
$users = UcpManager::saveUser($db, $payload);
echo json_encode([
'success' => true,
'users' => $users,
'ucp_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,
'ucp_users' => $users,
'message' => 'Compte utilisateur UCP supprimé.',
]);
break;
case 'get_cdr': case 'get_cdr':
$filters = [ $filters = [
'limit' => (int)($payload['limit'] ?? $_GET['limit'] ?? 100), 'limit' => (int)($payload['limit'] ?? $_GET['limit'] ?? 100),
+563 -67
View File
@@ -28,6 +28,31 @@ const chartHistory = {
let metricsTimer = null; let metricsTimer = null;
function adminIcon(name, cls = 'w-3.5 h-3.5') {
const paths = {
shield: '<path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75L11.25 15 15 9.75m-3-7.036A11.959 11.959 0 013.598 6 11.99 11.99 0 003 9.749c0 5.592 3.824 10.29 9 11.623 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.571-.598-3.751h-.152c-3.196 0-6.1-1.248-8.25-3.285z"/>',
lock: '<path stroke-linecap="round" stroke-linejoin="round" d="M16.5 10.5V6.75a4.5 4.5 0 10-9 0v3.75m-.75 11.25h10.5a2.25 2.25 0 002.25-2.25v-6.75a2.25 2.25 0 00-2.25-2.25H6.75a2.25 2.25 0 00-2.25 2.25v6.75a2.25 2.25 0 002.25 2.25z"/>',
unlock: '<path stroke-linecap="round" stroke-linejoin="round" d="M13.5 10.5V6.75a4.5 4.5 0 119 0v3.75M3.75 21.75h10.5a2.25 2.25 0 002.25-2.25v-6.75a2.25 2.25 0 00-2.25-2.25H3.75a2.25 2.25 0 00-2.25 2.25v6.75a2.25 2.25 0 002.25 2.25z"/>',
warning: '<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z"/>',
phone: '<path stroke-linecap="round" stroke-linejoin="round" d="M2.25 6.75c0 8.284 6.716 15 15 15h2.25a2.25 2.25 0 002.25-2.25v-1.372c0-.516-.351-.966-.852-1.091l-4.423-1.106c-.44-.11-.902.055-1.173.417l-.97 1.293c-.282.376-.769.542-1.21.38a12.035 12.035 0 01-7.143-7.143c-.162-.441.004-.928.38-1.21l1.293-.97c.363-.271.527-.734.417-1.173L6.963 3.102a1.125 1.125 0 00-1.091-.852H4.5A2.25 2.25 0 002.25 4.5v2.25z"/>',
mobile: '<path stroke-linecap="round" stroke-linejoin="round" d="M10.5 1.5H8.25A2.25 2.25 0 006 3.75v16.5a2.25 2.25 0 002.25 2.25h7.5A2.25 2.25 0 0018 20.25V3.75a2.25 2.25 0 00-2.25-2.25H13.5m-3 0V3h3V1.5m-3 0h3m-3 18.75h3"/>',
building: '<path stroke-linecap="round" stroke-linejoin="round" d="M3.75 21h16.5M4.5 3h15M5.25 3v18m13.5-18v18M9 6.75h1.5m-1.5 3h1.5m-1.5 3h1.5m3-6H15m-1.5 3H15m-1.5 3H15M9 21v-3.375c0-.621.504-1.125 1.125-1.125h3.75c.621 0 1.125.504 1.125 1.125V21"/>',
voicemail: '<path stroke-linecap="round" stroke-linejoin="round" d="M21.75 9v.906a2.25 2.25 0 01-1.183 1.981l-6.478 3.488M2.25 9v.906a2.25 2.25 0 001.183 1.981l6.478 3.488m8.839 2.51l-4.66-2.51m0 0l-1.023-.55a2.25 2.25 0 00-2.134 0l-1.022.55m0 0l-4.661 2.51m16.5 1.615a2.25 2.25 0 01-2.25 2.25h-15a2.25 2.25 0 01-2.25-2.25V8.844a2.25 2.25 0 011.183-1.98l7.5-4.04a2.25 2.25 0 012.134 0l7.5 4.04a2.25 2.25 0 011.183 1.98V19.5z"/>',
music: '<path stroke-linecap="round" stroke-linejoin="round" d="M9 9l10.5-3m0 6.553v3.75a2.25 2.25 0 01-1.632 2.163l-1.32.377a1.803 1.803 0 11-.99-3.467l2.31-.66a2.25 2.25 0 001.632-2.163zm0 0V2.25L9 5.25v10.303m0 0v3.75a2.25 2.25 0 01-1.632 2.163l-1.32.377a1.803 1.803 0 01-.99-3.467l2.31-.66A2.25 2.25 0 009 15.553z"/>',
refresh: '<path stroke-linecap="round" stroke-linejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99"/>',
trash: '<path stroke-linecap="round" stroke-linejoin="round" d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0"/>',
tag: '<path stroke-linecap="round" stroke-linejoin="round" d="M9.568 3H5.25A2.25 2.25 0 003 5.25v4.318c0 .597.237 1.17.659 1.591l9.581 9.581c.699.699 1.78.872 2.607.33a18.095 18.095 0 005.223-5.223c.542-.827.369-1.908-.33-2.607L11.16 3.66A2.25 2.25 0 009.568 3z"/>',
ban: '<path stroke-linecap="round" stroke-linejoin="round" d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636"/>',
speaker: '<path stroke-linecap="round" stroke-linejoin="round" d="M19.114 5.636a9 9 0 010 12.728M16.463 8.288a5.25 5.25 0 010 7.424M6.75 8.25l4.72-4.72a.75.75 0 011.28.53v15.88a.75.75 0 01-1.28.53l-4.72-4.72H4.51c-.88 0-1.704-.507-1.938-1.354A9.01 9.01 0 012.25 12c0-.83.112-1.633.322-2.396C2.806 8.756 3.63 8.25 4.51 8.25H6.75z"/>',
rocket: '<path stroke-linecap="round" stroke-linejoin="round" d="M15.59 14.37a6 6 0 01-5.84 7.38v-4.8m5.84-2.58a14.98 14.98 0 006.16-12.12A14.98 14.98 0 009.631 8.41m5.96 5.96a14.926 14.926 0 01-5.841 2.58m-.119-8.54a6 6 0 00-7.381 5.84h4.8m2.581-5.84a14.927 14.927 0 00-2.58 5.84m2.699 2.7c-.103.021-.207.041-.311.06a15.09 15.09 0 01-2.448-2.448 14.9 14.9 0 01.06-.312m-2.24 2.39a4.493 4.493 0 00-1.757 4.306 4.493 4.493 0 004.306-1.758M16.5 9a1.5 1.5 0 11-3 0 1.5 1.5 0 013 0z"/>',
emergency: '<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m0-10.036A11.959 11.959 0 013.598 6 11.99 11.99 0 003 9.75c0 5.592 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.57-.598-3.75h-.152c-3.196 0-6.1-1.249-8.25-3.286zm0 13.036h.008v.008H12v-.008z"/>',
clock: '<path stroke-linecap="round" stroke-linejoin="round" d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z"/>',
globe: '<path stroke-linecap="round" stroke-linejoin="round" d="M12 21a9.004 9.004 0 008.716-6.747M12 21a9.004 9.004 0 01-8.716-6.747M12 21c2.485 0 4.5-4.03 4.5-9S14.485 3 12 3m0 18c-2.485 0-4.5-4.03-4.5-9S9.515 3 12 3"/>',
dotGreen: '<circle cx="12" cy="12" r="5" fill="currentColor" stroke="none"/>',
};
return `<svg class="${cls} shrink-0 inline-block" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24">${paths[name] || paths.tag}</svg>`;
}
// --- Notifications Toast Tailwind --- // --- Notifications Toast Tailwind ---
function showToast(message, type = 'success') { function showToast(message, type = 'success') {
const container = document.getElementById('toast-container'); const container = document.getElementById('toast-container');
@@ -211,6 +236,7 @@ function renderAll() {
renderFeatureCodes(); renderFeatureCodes();
renderSipSettings(); renderSipSettings();
renderCertificates(); renderCertificates();
renderUcpTab();
renderSettingsAndDiagnostics(); renderSettingsAndDiagnostics();
} }
@@ -356,6 +382,8 @@ function renderSummaryCards() {
if (pagingBadge) pagingBadge.textContent = (appState.paging_groups || []).length; if (pagingBadge) pagingBadge.textContent = (appState.paging_groups || []).length;
const pbBadge = document.getElementById('nav-badge-phonebook'); const pbBadge = document.getElementById('nav-badge-phonebook');
if (pbBadge) pbBadge.textContent = (appState.phonebook_contacts || []).length; 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; document.getElementById('nav-badge-trunks').textContent = trks.length;
const obBadge = document.getElementById('nav-badge-outbound'); const obBadge = document.getElementById('nav-badge-outbound');
if (obBadge) obBadge.textContent = obRoutes.length; if (obBadge) obBadge.textContent = obRoutes.length;
@@ -762,11 +790,11 @@ function renderDashboardPhonesTable() {
const encMode = Number(ext.encryption ?? (isTls ? 1 : 0)); const encMode = Number(ext.encryption ?? (isTls ? 1 : 0));
let srtpBadge = ''; let srtpBadge = '';
if (encMode === 2) { if (encMode === 2) {
srtpBadge = `<span class="inline-flex items-center gap-1 px-1.5 py-0.2 rounded text-[10px] font-semibold bg-emerald-500/15 text-emerald-300 border border-emerald-500/30" title="Chiffrement média SRTP obligatoire (AES-128)">🛡️ SRTP Forcé</span>`; srtpBadge = `<span class="inline-flex items-center gap-1 px-1.5 py-0.2 rounded text-[10px] font-semibold bg-emerald-500/15 text-emerald-300 border border-emerald-500/30" title="Chiffrement média SRTP obligatoire (AES-128)">${adminIcon('shield', 'w-3 h-3')} SRTP Forcé</span>`;
} else if (encMode === 1) { } else if (encMode === 1) {
srtpBadge = `<span class="inline-flex items-center gap-1 px-1.5 py-0.2 rounded text-[10px] font-semibold bg-cyan-500/15 text-cyan-300 border border-cyan-500/30" title="Chiffrement média SRTP négociable si supporté par le poste">🛡️ SRTP Négociable</span>`; srtpBadge = `<span class="inline-flex items-center gap-1 px-1.5 py-0.2 rounded text-[10px] font-semibold bg-cyan-500/15 text-cyan-300 border border-cyan-500/30" title="Chiffrement média SRTP négociable si supporté par le poste">${adminIcon('shield', 'w-3 h-3')} SRTP Négociable</span>`;
} else { } else {
srtpBadge = `<span class="inline-flex items-center gap-1 px-1.5 py-0.2 rounded text-[10px] font-medium bg-anthracite-900 text-slate-400 border border-anthracite-700" title="Voix en clair (RTP standard)">🔈 RTP Clair</span>`; srtpBadge = `<span class="inline-flex items-center gap-1 px-1.5 py-0.2 rounded text-[10px] font-medium bg-anthracite-900 text-slate-400 border border-anthracite-700" title="Voix en clair (RTP standard)">${adminIcon('speaker', 'w-3 h-3')} RTP Clair</span>`;
} }
// Détection dynamique du mode réel négocié en direct par le téléphone // Détection dynamique du mode réel négocié en direct par le téléphone
@@ -779,21 +807,21 @@ function renderDashboardPhonesTable() {
if (actTr === 'tls') { if (actTr === 'tls') {
trPill = ` trPill = `
<span class="inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-xs font-bold bg-emerald-500/20 text-emerald-300 border border-emerald-500/40 shadow-sm shadow-emerald-500/10"> <span class="inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-xs font-bold bg-emerald-500/20 text-emerald-300 border border-emerald-500/40 shadow-sm shadow-emerald-500/10">
🔒 TLS Actif (Chiffré) ${adminIcon('lock', 'w-3 h-3')} TLS Actif (Chiffré)
</span> </span>
`; `;
profileNote = `<span class="${isTls ? 'text-emerald-300 font-semibold' : 'text-slate-300'}">${isTls ? 'TLS Strict' : 'UDP Interne'}</span>`; profileNote = `<span class="${isTls ? 'text-emerald-300 font-semibold' : 'text-slate-300'}">${isTls ? 'TLS Strict' : 'UDP Interne'}</span>`;
} else if (actTr === 'wss') { } else if (actTr === 'wss') {
trPill = ` trPill = `
<span class="inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-xs font-bold bg-cyan-500/20 text-cyan-300 border border-cyan-500/40"> <span class="inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-xs font-bold bg-cyan-500/20 text-cyan-300 border border-cyan-500/40">
🔒 WSS WebRTC ${adminIcon('lock', 'w-3 h-3')} WSS WebRTC
</span> </span>
`; `;
profileNote = `<span class="text-cyan-300 font-semibold">WebRTC</span>`; profileNote = `<span class="text-cyan-300 font-semibold">WebRTC</span>`;
} else if (actTr === 'tcp') { } else if (actTr === 'tcp') {
trPill = ` trPill = `
<span class="inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-xs font-semibold bg-indigo-500/20 text-indigo-300 border border-indigo-500/35"> <span class="inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-xs font-semibold bg-indigo-500/20 text-indigo-300 border border-indigo-500/35">
🔓 TCP Actif (Clair) ${adminIcon('unlock', 'w-3 h-3')} TCP Actif (Clair)
</span> </span>
`; `;
profileNote = `<span class="text-slate-300">${isTls ? 'TLS Strict' : 'UDP Interne'}</span>`; profileNote = `<span class="text-slate-300">${isTls ? 'TLS Strict' : 'UDP Interne'}</span>`;
@@ -801,10 +829,10 @@ function renderDashboardPhonesTable() {
// UDP // UDP
trPill = ` trPill = `
<span class="inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-xs font-semibold ${isTls ? 'bg-amber-500/20 text-amber-300 border border-amber-500/40' : 'bg-anthracite-750 text-slate-200 border border-anthracite-600'}"> <span class="inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-xs font-semibold ${isTls ? 'bg-amber-500/20 text-amber-300 border border-amber-500/40' : 'bg-anthracite-750 text-slate-200 border border-anthracite-600'}">
🔓 UDP Actif (Clair) ${adminIcon('unlock', 'w-3 h-3')} UDP Actif (Clair)
</span> </span>
`; `;
profileNote = isTls ? '<span class="text-amber-400 font-semibold">⚠️ Exige TLS</span>' : '<span class="text-slate-400">UDP Interne</span>'; profileNote = isTls ? `<span class="text-amber-400 font-semibold inline-flex items-center gap-1">${adminIcon('warning', 'w-3 h-3')} Exige TLS</span>` : '<span class="text-slate-400">UDP Interne</span>';
} }
modeBadge = ` modeBadge = `
@@ -850,7 +878,7 @@ function renderDashboardPhonesTable() {
contactHtml = ` contactHtml = `
<div> <div>
<div class="font-mono text-xs text-slate-200 font-semibold">${escapeHtml(cleanIp)}</div> <div class="font-mono text-xs text-slate-200 font-semibold">${escapeHtml(cleanIp)}</div>
${ua ? `<div class="text-[11px] text-indigo-300 font-medium truncate max-w-[240px]" title="${ua}">📱 ${ua}</div>` : ''} ${ua ? `<div class="text-[11px] text-indigo-300 font-medium truncate max-w-[240px]" title="${ua}">${adminIcon('mobile', 'w-3 h-3 text-indigo-400')} ${ua}</div>` : ''}
</div> </div>
`; `;
} else { } else {
@@ -906,10 +934,10 @@ function renderExtensionsTable() {
encLabel = '<span class="text-[10px] text-slate-400">RTP Clair</span>'; encLabel = '<span class="text-[10px] text-slate-400">RTP Clair</span>';
} }
const badge = isTls const badge = isTls
? `<span class="inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-xs font-semibold bg-emerald-500/15 text-emerald-300 border border-emerald-500/30">🔒 TLS (${encLabel})</span>` ? `<span class="inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-xs font-semibold bg-emerald-500/15 text-emerald-300 border border-emerald-500/30">${adminIcon('lock', 'w-3.5 h-3.5 text-emerald-400')} TLS (${encLabel})</span>`
: (encMode > 0 : (encMode > 0
? `<span class="inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-xs font-semibold bg-indigo-500/15 text-indigo-300 border border-indigo-500/30">🛡️ UDP (${encLabel})</span>` ? `<span class="inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-xs font-semibold bg-indigo-500/15 text-indigo-300 border border-indigo-500/30">${adminIcon('shield', 'w-3.5 h-3.5 text-indigo-400')} UDP (${encLabel})</span>`
: `<span class="inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-xs font-semibold bg-anthracite-750 text-slate-200 border border-anthracite-600">🏢 UDP (RTP Clair)</span>`); : `<span class="inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-xs font-semibold bg-anthracite-750 text-slate-200 border border-anthracite-600">${adminIcon('building', 'w-3.5 h-3.5 text-slate-400')} UDP (RTP Clair)</span>`);
const actTr = c ? (c.active_transport || 'udp').toUpperCase() : ''; const actTr = c ? (c.active_transport || 'udp').toUpperCase() : '';
const stLower = c ? String(c.status).toLowerCase() : ''; const stLower = c ? String(c.status).toLowerCase() : '';
@@ -932,16 +960,20 @@ function renderExtensionsTable() {
: `<span class="text-slate-500 text-xs italic">Aucun</span>`; : `<span class="text-slate-500 text-xs italic">Aucun</span>`;
const vmBadge = Number(ext.voicemail_enabled) === 1 const vmBadge = Number(ext.voicemail_enabled) === 1
? `<span class="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-[10px] font-semibold bg-amber-500/15 text-amber-300 border border-amber-500/30" title="Boîte vocale active (${ext.ring_time || 20}s)">📟 Boîte</span>` ? `<span class="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-[10px] font-semibold bg-amber-500/15 text-amber-300 border border-amber-500/30" title="Boîte vocale active (${ext.ring_time || 20}s)">${adminIcon('voicemail', 'w-3 h-3')} Boîte</span>`
: '';
const gwBadge = Number(ext.groundwire_enabled) === 1
? `<span class="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-[10px] font-semibold bg-cyan-500/15 text-cyan-300 border border-cyan-500/30" title="Provisionnement XML GroundWire activé dans l'UCP">GroundWire</span>`
: ''; : '';
return ` return `
<tr class="hover:bg-anthracite-750/60 transition"> <tr class="hover:bg-anthracite-750/60 transition">
<td class="py-3.5 px-4 font-mono font-bold text-indigo-300">${escapeHtml(ext.extension)}</td> <td class="py-3.5 px-4 font-mono font-bold text-indigo-300">${escapeHtml(ext.extension)}</td>
<td class="py-3.5 px-4 font-medium text-white"> <td class="py-3.5 px-4 font-medium text-white">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2 flex-wrap">
<span>${escapeHtml(ext.name)}</span> <span>${escapeHtml(ext.name)}</span>
${vmBadge} ${vmBadge}
${gwBadge}
</div> </div>
</td> </td>
<td class="py-3.5 px-4"><div class="flex flex-wrap gap-1">${groupBadges}</div></td> <td class="py-3.5 px-4"><div class="flex flex-wrap gap-1">${groupBadges}</div></td>
@@ -951,10 +983,21 @@ function renderExtensionsTable() {
<td class="py-3.5 px-4"> <td class="py-3.5 px-4">
<code class="px-2 py-0.5 rounded bg-anthracite-950 border border-anthracite-700 text-xs font-mono text-slate-300">${escapeHtml(ext.secret)}</code> <code class="px-2 py-0.5 rounded bg-anthracite-950 border border-anthracite-700 text-xs font-mono text-slate-300">${escapeHtml(ext.secret)}</code>
</td> </td>
<td class="py-3.5 px-4 text-right space-x-2 whitespace-nowrap"> <td class="py-3.5 px-4 text-right whitespace-nowrap">
<button type="button" onclick="promptRebootExtension(${Number(ext.id)})" class="text-xs font-semibold text-amber-400 hover:text-amber-300 transition">Redémarrer</button> <div class="inline-flex items-center justify-end gap-1.5">
<button type="button" onclick="editExtension(${Number(ext.id)})" class="text-xs font-semibold text-indigo-300 hover:text-indigo-200 transition">Modifier</button> <button type="button" onclick="promptRebootExtension(${Number(ext.id)})"
<button type="button" onclick="deleteExtension(${Number(ext.id)})" class="text-xs font-semibold text-red-400 hover:text-red-300 transition">Supprimer</button> class="px-2.5 py-1 rounded-lg bg-amber-500/10 hover:bg-amber-500/20 text-amber-300 hover:text-amber-200 border border-amber-500/30 text-xs font-semibold transition">
Redémarrer
</button>
<button type="button" onclick="editExtension(${Number(ext.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">
Modifier
</button>
<button type="button" onclick="deleteExtension(${Number(ext.id)})"
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>
</div>
</td> </td>
</tr> </tr>
`; `;
@@ -1024,6 +1067,9 @@ function openExtensionModal(ext = null) {
}); });
syncEncryptionToggle(transport, ext ? (ext.encryption ?? (transport === 'tls' ? 1 : 0)) : (transport === 'tls' ? 1 : 0)); syncEncryptionToggle(transport, ext ? (ext.encryption ?? (transport === 'tls' ? 1 : 0)) : (transport === 'tls' ? 1 : 0));
const gwCheckbox = document.getElementById('ext-groundwire-enabled');
if (gwCheckbox) gwCheckbox.checked = ext ? (Number(ext.groundwire_enabled) === 1) : false;
// Réinitialisation de l'onglet actif vers "Général" // Réinitialisation de l'onglet actif vers "Général"
switchExtModalTab('general'); switchExtModalTab('general');
@@ -1235,7 +1281,7 @@ function renderGroupsTable() {
? members.map(m => `<span class="px-2 py-0.5 rounded bg-indigo-500/20 text-indigo-200 border border-indigo-500/30 font-mono text-xs font-semibold">${escapeHtml(m)}</span>`).join(' ') ? members.map(m => `<span class="px-2 py-0.5 rounded bg-indigo-500/20 text-indigo-200 border border-indigo-500/30 font-mono text-xs font-semibold">${escapeHtml(m)}</span>`).join(' ')
: '<span class="text-slate-500 text-xs italic">Aucun membre</span>'; : '<span class="text-slate-500 text-xs italic">Aucun membre</span>';
const failoverLabel = String(grp.failover_dest || '').startsWith('vm-') const failoverLabel = String(grp.failover_dest || '').startsWith('vm-')
? `<span class="px-2 py-0.5 rounded bg-violet-500/20 text-violet-200 border border-violet-500/30 font-mono text-xs font-semibold">📬 Répondeur ${escapeHtml(String(grp.failover_dest).slice(3))}</span>` ? `<span class="inline-flex items-center gap-1 px-2 py-0.5 rounded bg-violet-500/20 text-violet-200 border border-violet-500/30 font-mono text-xs font-semibold">${adminIcon('voicemail', 'w-3.5 h-3.5')} Répondeur ${escapeHtml(String(grp.failover_dest).slice(3))}</span>`
: `<code class="text-slate-300">${escapeHtml(grp.failover_dest)}</code>`; : `<code class="text-slate-300">${escapeHtml(grp.failover_dest)}</code>`;
return ` return `
@@ -1752,7 +1798,7 @@ function renderTrunksTable() {
const mohSound = (appState.sounds || []).find(s => Number(s.id) === Number(t.moh_sound_id)); const mohSound = (appState.sounds || []).find(s => Number(s.id) === Number(t.moh_sound_id));
const mohBadge = mohSound const mohBadge = mohSound
? `<span class="inline-flex items-center gap-1 px-1.5 py-0.5 rounded bg-amber-500/15 text-amber-300 border border-amber-500/30 text-[10px] font-semibold">🎵 ${escapeHtml(mohSound.name)}</span>` ? `<span class="inline-flex items-center gap-1 px-1.5 py-0.5 rounded bg-amber-500/15 text-amber-300 border border-amber-500/30 text-[10px] font-semibold">${adminIcon('music', 'w-3 h-3')} ${escapeHtml(mohSound.name)}</span>`
: `<span class="text-[10px] text-slate-500">MoH : Globale</span>`; : `<span class="text-[10px] text-slate-500">MoH : Globale</span>`;
return ` return `
@@ -1782,12 +1828,12 @@ function renderTrunksTable() {
${t.cid_name_prefix ? `<span class="px-1.5 py-0.5 rounded bg-emerald-500/20 text-emerald-300 text-[10px] font-mono mr-1 font-semibold">CID: ${escapeHtml(t.cid_name_prefix)}</span>` : ''} ${t.cid_name_prefix ? `<span class="px-1.5 py-0.5 rounded bg-emerald-500/20 text-emerald-300 text-[10px] font-mono mr-1 font-semibold">CID: ${escapeHtml(t.cid_name_prefix)}</span>` : ''}
${didCount > 0 ? `<span class="text-indigo-300 font-semibold">${didCount} règle(s) entrante(s)</span>` : 'Tous numéros (Any)'} ${didCount > 0 ? `<span class="text-indigo-300 font-semibold">${didCount} règle(s) entrante(s)</span>` : 'Tous numéros (Any)'}
${blockCount > 0 ? `<span class="ml-1 px-1.5 py-0.5 rounded bg-amber-500/20 text-amber-300 text-[10px] font-semibold">${blockCount} blocage(s) CID</span>` : ''} ${blockCount > 0 ? `<span class="ml-1 px-1.5 py-0.5 rounded bg-amber-500/20 text-amber-300 text-[10px] font-semibold">${blockCount} blocage(s) CID</span>` : ''}
${tcCount > 0 ? `<span class="ml-1 px-1.5 py-0.5 rounded bg-amber-500/20 text-amber-300 text-[10px] font-semibold">⏱️ ${tcCount} horaire(s)</span>` : ''} ${tcCount > 0 ? `<span class="ml-1 inline-flex items-center gap-1 px-1.5 py-0.5 rounded bg-amber-500/20 text-amber-300 text-[10px] font-semibold">${adminIcon('clock', 'w-3 h-3')} ${tcCount} horaire(s)</span>` : ''}
</div> </div>
<div class="mt-1">${mohBadge}</div> <div class="mt-1">${mohBadge}</div>
</td> </td>
<td class="py-3.5 px-4 text-right space-x-2"> <td class="py-3.5 px-4 text-right space-x-2">
${wantsReg ? `<button type="button" onclick="forceRegisterTrunk('${escapeHtml(slug)}-reg')" title="Forcer l'envoi immédiat d'un SIP REGISTER vers l'opérateur pour reprendre la ligne" class="px-2 py-1 rounded-lg bg-cyan-500/15 hover:bg-cyan-500/25 border border-cyan-500/30 text-xs font-semibold text-cyan-300 transition">🔄 Re-Register</button>` : ''} ${wantsReg ? `<button type="button" onclick="forceRegisterTrunk('${escapeHtml(slug)}-reg')" title="Forcer l'envoi immédiat d'un SIP REGISTER vers l'opérateur pour reprendre la ligne" class="inline-flex items-center gap-1 px-2 py-1 rounded-lg bg-cyan-500/15 hover:bg-cyan-500/25 border border-cyan-500/30 text-xs font-semibold text-cyan-300 transition">${adminIcon('refresh', 'w-3.5 h-3.5')} Re-Register</button>` : ''}
<button type="button" onclick="editTrunk(${Number(t.id)}, 'inbound')" class="text-xs font-semibold text-emerald-300 hover:text-emerald-200">Routes Entrantes</button> <button type="button" onclick="editTrunk(${Number(t.id)}, 'inbound')" class="text-xs font-semibold text-emerald-300 hover:text-emerald-200">Routes Entrantes</button>
<button type="button" onclick="editTrunk(${Number(t.id)})" class="text-xs font-semibold text-indigo-300 hover:text-indigo-200">Modifier</button> <button type="button" onclick="editTrunk(${Number(t.id)})" class="text-xs font-semibold text-indigo-300 hover:text-indigo-200">Modifier</button>
<button type="button" onclick="deleteTrunk(${Number(t.id)})" class="text-xs font-semibold text-red-400 hover:text-red-300">Supprimer</button> <button type="button" onclick="deleteTrunk(${Number(t.id)})" class="text-xs font-semibold text-red-400 hover:text-red-300">Supprimer</button>
@@ -1861,7 +1907,7 @@ function buildDestinationOptionsHtml(selectedValue = 'ext-101') {
let html = ''; let html = '';
if (exts.length > 0) { if (exts.length > 0) {
html += `<optgroup label="📱 Postes SIP (Extensions)">`; html += `<optgroup label="Postes SIP (Extensions)">`;
exts.forEach(e => { exts.forEach(e => {
const val = `ext-${e.extension}`; const val = `ext-${e.extension}`;
html += `<option value="${escapeHtml(val)}" ${normVal === val ? 'selected' : ''}>Poste: ${escapeHtml(e.extension)} — ${escapeHtml(e.name)}</option>`; html += `<option value="${escapeHtml(val)}" ${normVal === val ? 'selected' : ''}>Poste: ${escapeHtml(e.extension)} — ${escapeHtml(e.name)}</option>`;
@@ -1870,7 +1916,7 @@ function buildDestinationOptionsHtml(selectedValue = 'ext-101') {
} }
if (grps.length > 0) { if (grps.length > 0) {
html += `<optgroup label="👥 Ring Groups (Groupes d'appel)">`; html += `<optgroup label="Ring Groups (Groupes d'appel)">`;
grps.forEach(g => { grps.forEach(g => {
const val = `grp-${g.extension}`; const val = `grp-${g.extension}`;
html += `<option value="${escapeHtml(val)}" ${normVal === val ? 'selected' : ''}>Ring Group: ${escapeHtml(g.extension)} — ${escapeHtml(g.name)}</option>`; html += `<option value="${escapeHtml(val)}" ${normVal === val ? 'selected' : ''}>Ring Group: ${escapeHtml(g.extension)} — ${escapeHtml(g.name)}</option>`;
@@ -1879,7 +1925,7 @@ function buildDestinationOptionsHtml(selectedValue = 'ext-101') {
} }
if (paging.length > 0) { if (paging.length > 0) {
html += `<optgroup label="📢 Téléavertissement & Interphonie (Paging)">`; html += `<optgroup label="Téléavertissement & Interphonie (Paging)">`;
paging.forEach(pg => { paging.forEach(pg => {
const val = `page-${pg.extension}`; const val = `page-${pg.extension}`;
html += `<option value="${escapeHtml(val)}" ${normVal === val ? 'selected' : ''}>Paging: ${escapeHtml(pg.extension)} — ${escapeHtml(pg.name)}</option>`; html += `<option value="${escapeHtml(val)}" ${normVal === val ? 'selected' : ''}>Paging: ${escapeHtml(pg.extension)} — ${escapeHtml(pg.name)}</option>`;
@@ -1888,7 +1934,7 @@ function buildDestinationOptionsHtml(selectedValue = 'ext-101') {
} }
if (ivrs.length > 0) { if (ivrs.length > 0) {
html += `<optgroup label="🎙️ Serveurs Vocaux (SVI / IVR)">`; html += `<optgroup label="Serveurs Vocaux (SVI / IVR)">`;
ivrs.forEach(i => { ivrs.forEach(i => {
const val = i.name; const val = i.name;
html += `<option value="${escapeHtml(val)}" ${normVal === val ? 'selected' : ''}>SVI / IVR: ${escapeHtml(i.name)} ${i.description ? '(' + escapeHtml(i.description) + ')' : ''}</option>`; html += `<option value="${escapeHtml(val)}" ${normVal === val ? 'selected' : ''}>SVI / IVR: ${escapeHtml(i.name)} ${i.description ? '(' + escapeHtml(i.description) + ')' : ''}</option>`;
@@ -1899,7 +1945,7 @@ function buildDestinationOptionsHtml(selectedValue = 'ext-101') {
const vmExts = exts.filter(e => Number(e.voicemail_enabled) === 1); const vmExts = exts.filter(e => Number(e.voicemail_enabled) === 1);
const hasMissingVm = normVal.startsWith('vm-') && !vmExts.some(e => `vm-${e.extension}` === normVal); const hasMissingVm = normVal.startsWith('vm-') && !vmExts.some(e => `vm-${e.extension}` === normVal);
if (vmExts.length > 0 || hasMissingVm) { if (vmExts.length > 0 || hasMissingVm) {
html += `<optgroup label="📬 Messagerie Vocale Directe (Répondeur)">`; html += `<optgroup label="Messagerie Vocale Directe (Répondeur)">`;
if (hasMissingVm) { if (hasMissingVm) {
const missingExt = normVal.slice(3); const missingExt = normVal.slice(3);
html += `<option value="${escapeHtml(normVal)}" selected>Répondeur: ${escapeHtml(missingExt)} (messagerie désactivée)</option>`; html += `<option value="${escapeHtml(normVal)}" selected>Répondeur: ${escapeHtml(missingExt)} (messagerie désactivée)</option>`;
@@ -1911,35 +1957,35 @@ function buildDestinationOptionsHtml(selectedValue = 'ext-101') {
html += `</optgroup>`; html += `</optgroup>`;
} }
// Piège à Spammeurs / Musique d'attente en boucle continue (MoH) // Musique d'attente en boucle continue (MoH)
const mohSounds = sounds.filter(s => s.usage_type === 'moh'); const mohSounds = sounds.filter(s => s.usage_type === 'moh');
html += `<optgroup label="🎵 Piège à Spammeurs (Musique d'attente en boucle infinie)">`; html += `<optgroup label="Musique d'attente (Boucle continue)">`;
html += `<option value="moh-default" ${normVal === 'moh-default' ? 'selected' : ''}>🎵 Musique MoH générale par défaut (en boucle infinie)</option>`; html += `<option value="moh-default" ${normVal === 'moh-default' ? 'selected' : ''}>Musique d'attente par défaut (boucle continue)</option>`;
mohSounds.forEach(s => { mohSounds.forEach(s => {
const val = `moh-${s.id}`; const val = `moh-${s.id}`;
html += `<option value="${escapeHtml(val)}" ${normVal === val ? 'selected' : ''}>🎵 Musique MoH dédiée : ${escapeHtml(s.name)} [${escapeHtml(s.site_category)}]</option>`; html += `<option value="${escapeHtml(val)}" ${normVal === val ? 'selected' : ''}>Musique d'attente : ${escapeHtml(s.name)} [${escapeHtml(s.site_category)}]</option>`;
}); });
html += `</optgroup>`; html += `</optgroup>`;
const announcements = sounds.filter(s => s.usage_type !== 'moh'); const announcements = sounds.filter(s => s.usage_type !== 'moh');
if (announcements.length > 0) { if (announcements.length > 0) {
html += `<optgroup label="📢 Annonces Vocales (Diffuser 1 fois puis raccrocher)">`; html += `<optgroup label="Annonces Vocales (Diffuser 1 fois puis raccrocher)">`;
announcements.forEach(s => { announcements.forEach(s => {
const val = `sound-${s.id}`; const val = `sound-${s.id}`;
html += `<option value="${escapeHtml(val)}" ${normVal === val ? 'selected' : ''}>Annonce : ${escapeHtml(s.name)} [${escapeHtml(s.site_category)}]</option>`; html += `<option value="${escapeHtml(val)}" ${normVal === val ? 'selected' : ''}>Annonce : ${escapeHtml(s.name)} [${escapeHtml(s.site_category)}]</option>`;
}); });
html += `</optgroup>`; html += `</optgroup>`;
html += `<optgroup label="👵 Piège Scammer / Fausse Voix en boucle infinie (Anti-Spam Tarpit)">`; html += `<optgroup label="Annonces Vocales en boucle continue">`;
announcements.forEach(s => { announcements.forEach(s => {
const val = `sound-loop-${s.id}`; const val = `sound-loop-${s.id}`;
html += `<option value="${escapeHtml(val)}" ${normVal === val ? 'selected' : ''}>👵 Voix en boucle infinie (ex: vieille dame) : ${escapeHtml(s.name)}</option>`; html += `<option value="${escapeHtml(val)}" ${normVal === val ? 'selected' : ''}>Annonce en boucle : ${escapeHtml(s.name)}</option>`;
}); });
html += `</optgroup>`; html += `</optgroup>`;
} }
html += ` html += `
<optgroup label="🚫 Terminer l'appel (Terminate Call)"> <optgroup label="Terminer l'appel (Terminate Call)">
<option value="terminate-hangup" ${normVal === 'terminate-hangup' || normVal === 'hangup' ? 'selected' : ''}>Raccrocher immédiatement (Hangup)</option> <option value="terminate-hangup" ${normVal === 'terminate-hangup' || normVal === 'hangup' ? 'selected' : ''}>Raccrocher immédiatement (Hangup)</option>
<option value="terminate-busy" ${normVal === 'terminate-busy' || normVal === 'busy' ? 'selected' : ''}>Tonalité d'occupation (Busy)</option> <option value="terminate-busy" ${normVal === 'terminate-busy' || normVal === 'busy' ? 'selected' : ''}>Tonalité d'occupation (Busy)</option>
<option value="terminate-congestion" ${normVal === 'terminate-congestion' || normVal === 'congestion' ? 'selected' : ''}>Tonalité de congestion / réseau saturé (Congestion)</option> <option value="terminate-congestion" ${normVal === 'terminate-congestion' || normVal === 'congestion' ? 'selected' : ''}>Tonalité de congestion / réseau saturé (Congestion)</option>
@@ -1951,6 +1997,209 @@ function buildDestinationOptionsHtml(selectedValue = 'ext-101') {
return html; return html;
} }
// -----------------------------------------------------------------------------
// SÉLECTEUR DE DESTINATION CUSTOM AVEC ICÔNES SVG (STYLE MENU LATÉRAL)
// -----------------------------------------------------------------------------
let activeDestDropdownMenu = null;
let activeDestDropdownSelect = null;
function getDestinationIconSvg(val, label = '') {
const v = String(val || '');
const l = String(label || '').toLowerCase();
const mk = (cls, path) => `<svg class="w-4 h-4 shrink-0 ${cls}" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24">${path}</svg>`;
if (v.startsWith('ext-') || l.includes('postes sip')) {
return mk('text-indigo-400', '<path stroke-linecap="round" stroke-linejoin="round" d="M2.25 6.75c0 8.284 6.716 15 15 15h2.25a2.25 2.25 0 002.25-2.25v-1.372c0-.516-.351-.966-.852-1.091l-4.423-1.106c-.44-.11-.902.055-1.173.417l-.97 1.293c-.282.376-.769.542-1.21.38a12.035 12.035 0 01-7.143-7.143c-.162-.441.004-.928.38-1.21l1.293-.97c.363-.271.527-.734.417-1.173L6.963 3.102a1.125 1.125 0 00-1.091-.852H4.5A2.25 2.25 0 002.25 4.5v2.25z"/>');
}
if (v.startsWith('grp-') || l.includes('ring groups')) {
return mk('text-cyan-400', '<path stroke-linecap="round" stroke-linejoin="round" d="M18 18.72a9.094 9.094 0 003.741-.479 3 3 0 00-4.682-2.72m.94 3.198l.001.031c0 .225-.012.447-.037.666A11.944 11.944 0 0112 21c-2.17 0-4.207-.576-5.963-1.584A6.062 6.062 0 016 18.719m12 0a5.971 5.971 0 00-.941-3.197m0 0A5.995 5.995 0 0012 12.75a5.995 5.995 0 00-5.058 2.772m0 0a3 3 0 00-4.681 2.72 8.986 8.986 0 003.74.477m.94-3.197a5.971 5.971 0 00-.94 3.197M15 6.75a3 3 0 11-6 0 3 3 0 016 0zm6 3a2.25 2.25 0 11-4.5 0 2.25 2.25 0 014.5 0zm-13.5 0a2.25 2.25 0 11-4.5 0 2.25 2.25 0 014.5 0z"/>');
}
if (v.startsWith('page-') || l.includes('paging') || l.includes('interphonie')) {
return mk('text-purple-400', '<path stroke-linecap="round" stroke-linejoin="round" d="M10.34 15.84c-.688-.06-1.386-.09-2.09-.09H7.5a4.5 4.5 0 110-9h.75c.704 0 1.402-.03 2.09-.09m0 9.18c.253.962.584 1.892.985 2.783.247.55.06 1.21-.463 1.511l-.657.38c-.551.318-1.26.117-1.527-.461a20.845 20.845 0 01-1.44-4.282m3.102.069a18.03 18.03 0 01-.59-4.59c0-1.586.205-3.124.59-4.59m0 9.18a23.848 23.848 0 018.835 2.535M10.34 6.66a23.847 23.847 0 008.835-2.535m0 0A23.74 23.74 0 0018.795 3m.38 1.125a23.91 23.91 0 011.014 5.395m-1.014 8.855c-.118.38-.245.754-.38 1.125m.38-1.125a23.91 23.91 0 001.014-5.395m0-3.46c.495.413.811 1.035.811 1.73 0 .695-.316 1.317-.811 1.73m0-3.46a24.347 24.347 0 010 3.46"/>');
}
if (v.startsWith('vm-') || l.includes('messagerie') || l.includes('répondeur')) {
return mk('text-amber-400', '<path stroke-linecap="round" stroke-linejoin="round" d="M21.75 9v.906a2.25 2.25 0 01-1.183 1.981l-6.478 3.488M2.25 9v.906a2.25 2.25 0 001.183 1.981l6.478 3.488m8.839 2.51l-4.66-2.51m0 0l-1.023-.55a2.25 2.25 0 00-2.134 0l-1.022.55m0 0l-4.661 2.51m16.5 1.615a2.25 2.25 0 01-2.25 2.25h-15a2.25 2.25 0 01-2.25-2.25V8.844a2.25 2.25 0 011.183-1.98l7.5-4.04a2.25 2.25 0 012.134 0l7.5 4.04a2.25 2.25 0 011.183 1.98V19.5z"/>');
}
if (v.startsWith('moh-') || l.includes('musique')) {
return mk('text-sky-400', '<path stroke-linecap="round" stroke-linejoin="round" d="M9 9l10.5-3m0 6.553v3.75a2.25 2.25 0 01-1.632 2.163l-1.32.377a1.803 1.803 0 11-.99-3.467l2.31-.66a2.25 2.25 0 001.632-2.163zm0 0V2.25L9 5.25v10.303m0 0v3.75a2.25 2.25 0 01-1.632 2.163l-1.32.377a1.803 1.803 0 01-.99-3.467l2.31-.66A2.25 2.25 0 009 15.553z"/>');
}
if (v.startsWith('sound-loop-') || l.includes('boucle continue')) {
return mk('text-violet-400', '<path stroke-linecap="round" stroke-linejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99"/>');
}
if (v.startsWith('sound-') || l.includes('annonces vocales')) {
return mk('text-teal-400', '<path stroke-linecap="round" stroke-linejoin="round" d="M19.114 5.636a9 9 0 010 12.728M16.463 8.288a5.25 5.25 0 010 7.424M6.75 8.25l4.72-4.72a.75.75 0 011.28.53v15.88a.75.75 0 01-1.28.53l-4.72-4.72H4.51c-.88 0-1.704-.507-1.938-1.354A9.01 9.01 0 012.25 12c0-.83.112-1.633.322-2.396C2.806 8.756 3.63 8.25 4.51 8.25H6.75z"/>');
}
if (v.startsWith('terminate-') || ['hangup', 'busy', 'congestion', 'zapateller', 'ringforever'].includes(v) || l.includes('terminer')) {
return mk('text-rose-400', '<path stroke-linecap="round" stroke-linejoin="round" d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636"/>');
}
return mk('text-emerald-400', '<path stroke-linecap="round" stroke-linejoin="round" d="M12 18.75a6 6 0 006-6v-1.5m-6 7.5a6 6 0 01-6-6v-1.5m6 7.5v3.75m-3.75 0h7.5M12 15.75a3 3 0 01-3-3V4.5a3 3 0 116 0v8.25a3 3 0 01-3 3z"/>');
}
function closeDestinationDropdown() {
if (activeDestDropdownMenu) {
activeDestDropdownMenu.remove();
activeDestDropdownMenu = null;
activeDestDropdownSelect = null;
}
}
function updateDestinationSelectTrigger(selectEl) {
const wrap = selectEl._nexusDestWrap;
if (!wrap) return;
const btn = wrap.querySelector('.nexus-dest-trigger');
if (!btn) return;
const opt = selectEl.options[selectEl.selectedIndex] || selectEl.options[0];
const val = opt ? opt.value : '';
const txt = opt ? opt.textContent : 'Sélectionner…';
btn.innerHTML = `
<span class="flex items-center gap-2 min-w-0">
${getDestinationIconSvg(val)}
<span class="truncate">${escapeHtml(txt)}</span>
</span>
<svg class="w-4 h-4 shrink-0 text-slate-400" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5"/>
</svg>
`;
}
function openDestinationDropdown(selectEl, triggerBtn) {
if (activeDestDropdownSelect === selectEl) {
closeDestinationDropdown();
return;
}
closeDestinationDropdown();
const menu = document.createElement('div');
menu.className = 'fixed z-[120] bg-anthracite-900 border border-anthracite-700 rounded-xl shadow-2xl shadow-black/70 overflow-hidden flex flex-col';
const listEl = document.createElement('div');
listEl.className = 'overflow-y-auto p-1.5 space-y-0.5 max-h-72';
const curVal = selectEl.value;
const appendOptionBtn = (opt) => {
const isSel = opt.value === curVal;
const item = document.createElement('button');
item.type = 'button';
item.className = `w-full flex items-center justify-between gap-2 px-2.5 py-1.5 rounded-lg text-left text-xs transition ${
isSel ? 'bg-indigo-600/25 text-white font-semibold' : 'text-slate-200 hover:bg-anthracite-800 hover:text-white'
}`;
item.innerHTML = `
<span class="flex items-center gap-2 min-w-0">
${getDestinationIconSvg(opt.value)}
<span class="truncate">${escapeHtml(opt.textContent)}</span>
</span>
${isSel ? '<svg class="w-3.5 h-3.5 shrink-0 text-indigo-400" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5"/></svg>' : ''}
`;
item.addEventListener('click', (e) => {
e.stopPropagation();
selectEl.value = opt.value;
updateDestinationSelectTrigger(selectEl);
selectEl.dispatchEvent(new Event('change', { bubbles: true }));
closeDestinationDropdown();
});
listEl.appendChild(item);
};
Array.from(selectEl.children).forEach(node => {
if (node.tagName === 'OPTGROUP') {
const hdr = document.createElement('div');
hdr.className = 'flex items-center gap-1.5 px-2 pt-2 pb-1 text-[10px] font-bold uppercase tracking-wider text-slate-400 select-none';
hdr.innerHTML = `${getDestinationIconSvg('', node.label)}<span>${escapeHtml(node.label)}</span>`;
listEl.appendChild(hdr);
Array.from(node.children).forEach(opt => {
if (opt.tagName === 'OPTION') appendOptionBtn(opt);
});
} else if (node.tagName === 'OPTION') {
appendOptionBtn(node);
}
});
menu.appendChild(listEl);
document.body.appendChild(menu);
const rect = triggerBtn.getBoundingClientRect();
const width = Math.max(rect.width, 280);
const spaceBelow = window.innerHeight - rect.bottom - 12;
const menuHeight = Math.min(296, menu.scrollHeight || 296);
const openUp = spaceBelow < 180 && rect.top > spaceBelow;
menu.style.width = `${Math.min(width, window.innerWidth - 16)}px`;
menu.style.left = `${Math.max(8, Math.min(rect.left, window.innerWidth - width - 8))}px`;
if (openUp) {
menu.style.bottom = `${window.innerHeight - rect.top + 4}px`;
} else {
menu.style.top = `${rect.bottom + 4}px`;
}
activeDestDropdownMenu = menu;
activeDestDropdownSelect = selectEl;
}
function enhanceDestinationSelect(selectEl) {
if (!selectEl || selectEl.tagName !== 'SELECT') return;
if (!selectEl.querySelector('option[value="terminate-hangup"]')) return;
if (!selectEl._nexusDestWrap) {
selectEl.classList.add('sr-only');
selectEl.setAttribute('tabindex', '-1');
const wrap = document.createElement('div');
wrap.className = 'relative w-full';
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'nexus-dest-trigger w-full flex items-center justify-between gap-2 px-3 py-2 rounded-xl bg-anthracite-950 border border-anthracite-700 hover:border-indigo-500/60 focus:border-indigo-500 focus:outline-none text-xs text-white text-left transition';
btn.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
openDestinationDropdown(selectEl, btn);
});
selectEl.parentNode.insertBefore(wrap, selectEl);
wrap.appendChild(selectEl);
wrap.appendChild(btn);
selectEl._nexusDestWrap = wrap;
selectEl.addEventListener('change', () => updateDestinationSelectTrigger(selectEl));
const optObs = new MutationObserver(() => updateDestinationSelectTrigger(selectEl));
optObs.observe(selectEl, { childList: true, subtree: true });
}
updateDestinationSelectTrigger(selectEl);
}
if (typeof document !== 'undefined' && !window._nexusDestSelectInit) {
window._nexusDestSelectInit = true;
document.addEventListener('click', (e) => {
if (activeDestDropdownMenu && !activeDestDropdownMenu.contains(e.target)) {
closeDestinationDropdown();
}
});
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') closeDestinationDropdown();
});
window.addEventListener('resize', closeDestinationDropdown);
document.addEventListener('scroll', (e) => {
if (activeDestDropdownMenu && !activeDestDropdownMenu.contains(e.target)) {
closeDestinationDropdown();
}
}, true);
const domObs = new MutationObserver((mutations) => {
for (const m of mutations) {
if (m.target && m.target.tagName === 'SELECT') {
enhanceDestinationSelect(m.target);
}
m.addedNodes?.forEach(n => {
if (n.nodeType !== 1) return;
if (n.tagName === 'SELECT') enhanceDestinationSelect(n);
else n.querySelectorAll?.('select').forEach(enhanceDestinationSelect);
});
}
});
document.addEventListener('DOMContentLoaded', () => {
document.querySelectorAll('select').forEach(enhanceDestinationSelect);
domObs.observe(document.body, { childList: true, subtree: true });
});
}
function updateInboundRoutesBadge() { function updateInboundRoutesBadge() {
const count = document.querySelectorAll('#trunk-did-container .trunk-did-row').length; const count = document.querySelectorAll('#trunk-did-container .trunk-did-row').length;
const badge = document.getElementById('trunk-inbound-rules-badge'); const badge = document.getElementById('trunk-inbound-rules-badge');
@@ -2006,11 +2255,11 @@ function addTrunkInboundRouteRow(cid = 'Any', did = 'Any', description = '', des
</select> </select>
</div> </div>
<div class="col-span-2 flex items-center justify-end gap-1.5"> <div class="col-span-2 flex items-center justify-end gap-1.5">
<button type="button" class="btn-toggle-time px-2 py-1.5 rounded-lg text-xs font-semibold border transition" title="Configurer les horaires d'ouverture (Mode Jour / Nuit)"> <button type="button" class="btn-toggle-time inline-flex items-center gap-1 px-2 py-1.5 rounded-lg text-xs font-semibold border transition" title="Configurer les horaires d'ouverture (Mode Jour / Nuit)">
⏱️ <span class="hidden sm:inline">Horaires</span> ${adminIcon('clock', 'w-3.5 h-3.5')} <span class="hidden sm:inline">Horaires</span>
</button> </button>
<button type="button" title="Dupliquer cette règle" class="btn-dup-route p-1.5 rounded-lg bg-anthracite-950 hover:bg-indigo-500/20 text-slate-300 hover:text-indigo-300 border border-anthracite-700 text-xs">⧉</button> <button type="button" title="Dupliquer cette règle" class="btn-dup-route p-1.5 rounded-lg bg-anthracite-950 hover:bg-indigo-500/20 text-slate-300 hover:text-indigo-300 border border-anthracite-700 text-xs">⧉</button>
<button type="button" title="Supprimer cette règle" class="btn-del-route p-1.5 rounded-lg bg-anthracite-950 hover:bg-red-500/20 text-slate-300 hover:text-red-300 border border-anthracite-700 text-xs">🗑️</button> <button type="button" title="Supprimer cette règle" class="btn-del-route p-1.5 rounded-lg bg-anthracite-950 hover:bg-red-500/20 text-slate-300 hover:text-red-300 border border-anthracite-700 text-xs">${adminIcon('trash', 'w-3.5 h-3.5')}</button>
</div> </div>
</div> </div>
@@ -2054,7 +2303,7 @@ function addTrunkInboundRouteRow(cid = 'Any', did = 'Any', description = '', des
<div class="flex flex-wrap items-center gap-2 pt-2 border-t border-anthracite-800 text-xs"> <div class="flex flex-wrap items-center gap-2 pt-2 border-t border-anthracite-800 text-xs">
<label class="text-[11px] font-semibold text-slate-300 flex items-center gap-1"> <label class="text-[11px] font-semibold text-slate-300 flex items-center gap-1">
<span>🏷️ Préfixe CID spécifique à cette règle :</span> <span>${adminIcon('tag', 'w-3.5 h-3.5 text-amber-400')} Préfixe CID spécifique à cette règle :</span>
</label> </label>
<input type="text" placeholder="ex: [Ligne-1] (vide = hériter du Trunk)" value="${escapeHtml(tcCidPrefix)}" <input type="text" placeholder="ex: [Ligne-1] (vide = hériter du Trunk)" value="${escapeHtml(tcCidPrefix)}"
class="trunk-did-prefix px-2.5 py-1 rounded bg-anthracite-900 border border-anthracite-700 text-xs text-white font-mono w-56 focus:outline-none focus:border-indigo-500"> class="trunk-did-prefix px-2.5 py-1 rounded bg-anthracite-900 border border-anthracite-700 text-xs text-white font-mono w-56 focus:outline-none focus:border-indigo-500">
@@ -2083,7 +2332,7 @@ function addTrunkInboundRouteRow(cid = 'Any', did = 'Any', description = '', des
if (isEnabled) { if (isEnabled) {
toggleBtn.className = 'btn-toggle-time px-2 py-1.5 rounded-lg text-xs font-semibold bg-amber-500/20 text-amber-300 border border-amber-500/40 transition shadow-sm'; toggleBtn.className = 'btn-toggle-time px-2 py-1.5 rounded-lg text-xs font-semibold bg-amber-500/20 text-amber-300 border border-amber-500/40 transition shadow-sm';
const daysLabel = tcWeekdaysSelect.value === 'mon-fri' ? 'Lun-Ven' : (tcWeekdaysSelect.value === 'mon-sat' ? 'Lun-Sam' : (tcWeekdaysSelect.value === '*' ? '7j/7' : 'Sam-Dim')); const daysLabel = tcWeekdaysSelect.value === 'mon-fri' ? 'Lun-Ven' : (tcWeekdaysSelect.value === 'mon-sat' ? 'Lun-Sam' : (tcWeekdaysSelect.value === '*' ? '7j/7' : 'Sam-Dim'));
badgeContainer.innerHTML = `<span class="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-[10px] font-semibold bg-amber-500/15 text-amber-300 border border-amber-500/30">⏱️ ${escapeHtml(tcStartInput.value)}-${escapeHtml(tcFinishInput.value)} (${escapeHtml(daysLabel)})</span>`; badgeContainer.innerHTML = `<span class="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-[10px] font-semibold bg-amber-500/15 text-amber-300 border border-amber-500/30">${adminIcon('clock', 'w-3 h-3')} ${escapeHtml(tcStartInput.value)}-${escapeHtml(tcFinishInput.value)} (${escapeHtml(daysLabel)})</span>`;
} else { } else {
toggleBtn.className = 'btn-toggle-time px-2 py-1.5 rounded-lg text-xs font-semibold bg-anthracite-950 hover:bg-anthracite-900 text-slate-400 hover:text-white border border-anthracite-700 transition'; toggleBtn.className = 'btn-toggle-time px-2 py-1.5 rounded-lg text-xs font-semibold bg-anthracite-950 hover:bg-anthracite-900 text-slate-400 hover:text-white border border-anthracite-700 transition';
badgeContainer.innerHTML = ''; badgeContainer.innerHTML = '';
@@ -2493,10 +2742,10 @@ function openTrunkModal(trunk = null) {
if (mohSelect) { if (mohSelect) {
const mohSounds = (appState.sounds || []).filter(s => s.usage_type === 'moh'); const mohSounds = (appState.sounds || []).filter(s => s.usage_type === 'moh');
const curMohId = trunk ? Number(trunk.moh_sound_id || 0) : 0; const curMohId = trunk ? Number(trunk.moh_sound_id || 0) : 0;
let opts = `<option value="0" ${curMohId === 0 ? 'selected' : ''}>🌐 Hériter de la musique générale par défaut</option>`; let opts = `<option value="0" ${curMohId === 0 ? 'selected' : ''}>Hériter de la musique générale par défaut</option>`;
mohSounds.forEach(s => { mohSounds.forEach(s => {
const isSel = (Number(s.id) === curMohId) ? 'selected' : ''; const isSel = (Number(s.id) === curMohId) ? 'selected' : '';
opts += `<option value="${s.id}" ${isSel}>🎵 ${escapeHtml(s.name)} (${escapeHtml(s.site_category || 'Global')})</option>`; opts += `<option value="${s.id}" ${isSel}>${escapeHtml(s.name)} (${escapeHtml(s.site_category || 'Global')})</option>`;
}); });
mohSelect.innerHTML = opts; mohSelect.innerHTML = opts;
} }
@@ -2597,7 +2846,7 @@ function renderOutboundRoutesTable() {
<span class="text-[10px] text-slate-400">${tIdx + 1}.</span>${escapeHtml(t)} <span class="text-[10px] text-slate-400">${tIdx + 1}.</span>${escapeHtml(t)}
</span> </span>
`).join(' <span class="text-slate-500 text-xs">→</span> ') `).join(' <span class="text-slate-500 text-xs">→</span> ')
: `<span class="text-amber-400 text-xs font-semibold">⚠️ Aucun Trunk associé</span>`; : `<span class="text-amber-400 text-xs font-semibold">${adminIcon('warning', 'w-3.5 h-3.5')} Aucun Trunk associé</span>`;
const isRestricted = r.permission_mode === 'restricted'; const isRestricted = r.permission_mode === 'restricted';
const allowedGrps = Array.isArray(r.allowed_groups) ? r.allowed_groups : []; const allowedGrps = Array.isArray(r.allowed_groups) ? r.allowed_groups : [];
@@ -2622,7 +2871,7 @@ function renderOutboundRoutesTable() {
const patPreview = patterns.slice(0, 2).map(p => escapeHtml(p.match_pattern || '')).filter(Boolean).join(', '); const patPreview = patterns.slice(0, 2).map(p => escapeHtml(p.match_pattern || '')).filter(Boolean).join(', ');
const rTypeBadge = r.route_type === 'emergency' const rTypeBadge = r.route_type === 'emergency'
? `<span class="px-2 py-0.5 rounded-full text-[10px] font-bold bg-amber-500/20 text-amber-300 border border-amber-500/40">🚑 Urgence</span>` ? `<span class="px-2 py-0.5 rounded-full text-[10px] font-bold bg-amber-500/20 text-amber-300 border border-amber-500/40">${adminIcon('emergency', 'w-3 h-3')} Urgence</span>`
: (r.route_type === 'intracompany' : (r.route_type === 'intracompany'
? `<span class="px-2 py-0.5 rounded-full text-[10px] font-bold bg-cyan-500/20 text-cyan-300 border border-cyan-500/40">Intra</span>` ? `<span class="px-2 py-0.5 rounded-full text-[10px] font-bold bg-cyan-500/20 text-cyan-300 border border-cyan-500/40">Intra</span>`
: `<span class="px-2 py-0.5 rounded-full text-[10px] font-medium bg-anthracite-950 text-slate-300 border border-anthracite-700">Standard</span>`); : `<span class="px-2 py-0.5 rounded-full text-[10px] font-medium bg-anthracite-950 text-slate-300 border border-anthracite-700">Standard</span>`);
@@ -2717,19 +2966,19 @@ function openOutboundRouteModal(route = null) {
if (congSel) { if (congSel) {
const curCong = route ? route.congestion_dest : 'all-circuits-busy'; const curCong = route ? route.congestion_dest : 'all-circuits-busy';
let congOpts = ` let congOpts = `
<optgroup label="📢 Messages Vocaux (Recommandé)"> <optgroup label="Messages Vocaux (Recommandé)">
<option value="all-circuits-busy" ${(curCong === 'all-circuits-busy' || (!route && curCong !== 'terminate-congestion')) ? 'selected' : ''}>📢 Message vocal standard (« Toutes les lignes sont occupées »)</option> <option value="all-circuits-busy" ${(curCong === 'all-circuits-busy' || (!route && curCong !== 'terminate-congestion')) ? 'selected' : ''}>Message vocal standard (« Toutes les lignes sont occupées »)</option>
`; `;
const annSounds = (appState.sounds || []).filter(s => s.usage_type !== 'moh'); const annSounds = (appState.sounds || []).filter(s => s.usage_type !== 'moh');
if (annSounds.length > 0) { if (annSounds.length > 0) {
annSounds.forEach(s => { annSounds.forEach(s => {
const val = `sound-${s.id}`; const val = `sound-${s.id}`;
congOpts += `<option value="${val}" ${curCong === val ? 'selected' : ''}>🎙️ Annonce : ${escapeHtml(s.name)}</option>`; congOpts += `<option value="${val}" ${curCong === val ? 'selected' : ''}>Annonce : ${escapeHtml(s.name)}</option>`;
}); });
} }
congOpts += ` congOpts += `
</optgroup> </optgroup>
<optgroup label="📴 Tonalités & Raccrochage"> <optgroup label="Tonalités & Raccrochage">
<option value="terminate-congestion" ${curCong === 'terminate-congestion' ? 'selected' : ''}>Tonalité de Congestion (Bip rapide réseau saturé)</option> <option value="terminate-congestion" ${curCong === 'terminate-congestion' ? 'selected' : ''}>Tonalité de Congestion (Bip rapide réseau saturé)</option>
<option value="terminate-busy" ${curCong === 'terminate-busy' ? 'selected' : ''}>Tonalité d'Occupation (Bip occupé)</option> <option value="terminate-busy" ${curCong === 'terminate-busy' ? 'selected' : ''}>Tonalité d'Occupation (Bip occupé)</option>
<option value="terminate-hangup" ${curCong === 'terminate-hangup' ? 'selected' : ''}>Raccrocher immédiatement</option> <option value="terminate-hangup" ${curCong === 'terminate-hangup' ? 'selected' : ''}>Raccrocher immédiatement</option>
@@ -2743,7 +2992,7 @@ function openOutboundRouteModal(route = null) {
if (mohSel) { if (mohSel) {
const curMoh = route ? (route.moh_class || 'default') : 'default'; const curMoh = route ? (route.moh_class || 'default') : 'default';
const mohSounds = (appState.sounds || []).filter(s => s.usage_type === 'moh'); const mohSounds = (appState.sounds || []).filter(s => s.usage_type === 'moh');
let mohOpts = `<option value="default" ${curMoh === 'default' ? 'selected' : ''}>🌐 Hériter du Trunk / Global (Recommandé)</option>`; let mohOpts = `<option value="default" ${curMoh === 'default' ? 'selected' : ''}>Hériter du Trunk / Global (Recommandé)</option>`;
mohSounds.forEach(s => { mohSounds.forEach(s => {
const sSlug = slugifyTrunkName(s.site_category || 'global'); const sSlug = slugifyTrunkName(s.site_category || 'global');
const cName = (sSlug === 'global' || sSlug === 'default') ? 'default' : `moh-${sSlug}`; const cName = (sSlug === 'global' || sSlug === 'default') ? 'default' : `moh-${sSlug}`;
@@ -2868,7 +3117,7 @@ function addOutboundTrunkSeqRow(selectedTrunk = '') {
<select class="ob-trunk-select flex-1 px-3 py-2 rounded-lg bg-anthracite-900 border border-anthracite-700 text-xs text-white font-medium"> <select class="ob-trunk-select flex-1 px-3 py-2 rounded-lg bg-anthracite-900 border border-anthracite-700 text-xs text-white font-medium">
${optsHtml || '<option value="">Aucun Trunk disponible (Créez un Trunk)</option>'} ${optsHtml || '<option value="">Aucun Trunk disponible (Créez un Trunk)</option>'}
</select> </select>
<button type="button" class="btn-del-trunk-row p-2 rounded-lg bg-anthracite-900 hover:bg-red-500/20 text-slate-400 hover:text-red-300 border border-anthracite-700 text-xs">🗑️</button> <button type="button" class="btn-del-trunk-row p-2 rounded-lg bg-anthracite-900 hover:bg-red-500/20 text-slate-400 hover:text-red-300 border border-anthracite-700 text-xs">${adminIcon('trash', 'w-3.5 h-3.5')}</button>
`; `;
row.querySelector('.btn-del-trunk-row').addEventListener('click', () => { row.querySelector('.btn-del-trunk-row').addEventListener('click', () => {
@@ -2915,7 +3164,7 @@ function addOutboundPatternRow(prepend = '', prefix = '', match = '', cid = '')
class="ob-pat-cid w-full px-2.5 py-1.5 rounded-lg bg-anthracite-950 border border-anthracite-700 text-xs text-indigo-300 font-mono"> class="ob-pat-cid w-full px-2.5 py-1.5 rounded-lg bg-anthracite-950 border border-anthracite-700 text-xs text-indigo-300 font-mono">
</div> </div>
<div class="col-span-1 text-right"> <div class="col-span-1 text-right">
<button type="button" class="btn-del-pat p-1.5 rounded-lg bg-anthracite-950 hover:bg-red-500/20 text-slate-400 hover:text-red-300 border border-anthracite-700 text-xs">🗑️</button> <button type="button" class="btn-del-pat p-1.5 rounded-lg bg-anthracite-950 hover:bg-red-500/20 text-slate-400 hover:text-red-300 border border-anthracite-700 text-xs">${adminIcon('trash', 'w-3.5 h-3.5')}</button>
</div> </div>
`; `;
@@ -3098,8 +3347,8 @@ function renderIvrsTable() {
</div> </div>
</td> </td>
<td class="py-3.5 px-4 text-xs text-slate-300"> <td class="py-3.5 px-4 text-xs text-slate-300">
<div>⏱️ Attente : <strong class="text-white">${Number(ivr.timeout)}s</strong> <span class="text-slate-400">(${Number(ivr.timeout_retries || 2)} rép)</span></div> <div class="inline-flex items-center gap-1">${adminIcon('clock', 'w-3.5 h-3.5 text-amber-400')} Attente : <strong class="text-white">${Number(ivr.timeout)}s</strong> <span class="text-slate-400">(${Number(ivr.timeout_retries || 2)} rép)</span></div>
<div class="text-[11px] text-slate-400 mt-0.5">⚠️ Erreur : max <strong class="text-slate-200">${Number(ivr.invalid_retries || 3)}</strong> essais</div> <div class="text-[11px] text-slate-400 mt-0.5">${adminIcon('warning', 'w-3.5 h-3.5 text-amber-400')} Erreur : max <strong class="text-slate-200">${Number(ivr.invalid_retries || 3)}</strong> essais</div>
</td> </td>
<td class="py-3.5 px-4"> <td class="py-3.5 px-4">
<span class="inline-flex items-center gap-1 px-2.5 py-1 rounded-lg bg-indigo-500/10 text-indigo-300 border border-indigo-500/30 text-xs font-mono font-bold" title="Composer ce code depuis un poste interne pour tester le SVI"> <span class="inline-flex items-center gap-1 px-2.5 py-1 rounded-lg bg-indigo-500/10 text-indigo-300 border border-indigo-500/30 text-xs font-mono font-bold" title="Composer ce code depuis un poste interne pour tester le SVI">
@@ -3173,7 +3422,7 @@ function openIvrModal(ivr = null) {
let audioHtml = ''; let audioHtml = '';
if (sounds.length > 0) { if (sounds.length > 0) {
audioHtml += `<optgroup label="📂 Fichiers sons de la médiathèque">`; audioHtml += `<optgroup label="Fichiers sons de la médiathèque">`;
sounds.forEach(s => { sounds.forEach(s => {
const val = `custom/${s.slug}`; const val = `custom/${s.slug}`;
const isSel = curAudio === val || curAudio === s.slug || curAudio === String(s.id); const isSel = curAudio === val || curAudio === s.slug || curAudio === String(s.id);
@@ -3183,7 +3432,7 @@ function openIvrModal(ivr = null) {
} }
audioHtml += ` audioHtml += `
<optgroup label="🔔 Messages système Asterisk par défaut"> <optgroup label="Messages système Asterisk par défaut">
<option value="demo-instruct" ${curAudio === 'demo-instruct' || !curAudio ? 'selected' : ''}>demo-instruct (Instructions d'accueil)</option> <option value="demo-instruct" ${curAudio === 'demo-instruct' || !curAudio ? 'selected' : ''}>demo-instruct (Instructions d'accueil)</option>
<option value="hello-world" ${curAudio === 'hello-world' ? 'selected' : ''}>hello-world (Message d'accueil basique)</option> <option value="hello-world" ${curAudio === 'hello-world' ? 'selected' : ''}>hello-world (Message d'accueil basique)</option>
<option value="beep" ${curAudio === 'beep' ? 'selected' : ''}>beep (Simple bip sonore)</option> <option value="beep" ${curAudio === 'beep' ? 'selected' : ''}>beep (Simple bip sonore)</option>
@@ -3629,6 +3878,7 @@ const PAGE_TITLES = {
system: 'Paramètres Globaux, Diagnostic Debian & Fichiers .conf', system: 'Paramètres Globaux, Diagnostic Debian & Fichiers .conf',
backups: 'Sauvegardes & Restauration Système (Disaster Recovery)', backups: 'Sauvegardes & Restauration Système (Disaster Recovery)',
firewall: 'Pare-feu Réseau & Sécurité Téléphonie (Fail2ban & NFTables)', 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)' cluster: 'Haute Disponibilité & Redondance (Cluster HA Keepalived)'
}; };
@@ -3709,6 +3959,11 @@ function switchTab(tabId) {
loadCluster(); loadCluster();
} }
// Si onglet Portail Utilisateur (UCP), actualiser l'affichage
if (tabId === 'ucp') {
renderUcpTab();
}
toggleMobileSidebar(false); toggleMobileSidebar(false);
} }
@@ -3820,6 +4075,7 @@ document.addEventListener('DOMContentLoaded', () => {
payload.voicemail_attach = document.getElementById('ext-vm-attach')?.checked ? 1 : 0; payload.voicemail_attach = document.getElementById('ext-vm-attach')?.checked ? 1 : 0;
payload.voicemail_delete = document.getElementById('ext-vm-delete')?.checked ? 1 : 0; payload.voicemail_delete = document.getElementById('ext-vm-delete')?.checked ? 1 : 0;
payload.ring_time = Number(payload.ring_time || 20); payload.ring_time = Number(payload.ring_time || 20);
payload.groundwire_enabled = document.getElementById('ext-groundwire-enabled')?.checked ? 1 : 0;
try { try {
const res = await apiCall('save_extension', payload); const res = await apiCall('save_extension', payload);
@@ -4346,19 +4602,29 @@ document.addEventListener('DOMContentLoaded', () => {
const btn = document.getElementById('btn-save-firewall-settings'); const btn = document.getElementById('btn-save-firewall-settings');
if (btn) btn.disabled = true; if (btn) btn.disabled = true;
const rawUcpHttps = f.elements['ucp_https_port']?.value;
const rawUcpHttp = f.elements['ucp_http_port']?.value;
const payload = { const payload = {
firewall_enabled: f.elements['firewall_enabled']?.checked ? 1 : 0, firewall_enabled: f.elements['firewall_enabled']?.checked ? 1 : 0,
admin_https_port: Number(f.elements['admin_https_port']?.value || 8443), admin_https_port: Number(f.elements['admin_https_port']?.value || 8443),
certbot_strict_port80: f.elements['certbot_strict_port80']?.checked ? 1 : 0, certbot_strict_port80: f.elements['certbot_strict_port80']?.checked ? 1 : 0,
fail2ban_sip_maxretry: Number(f.elements['fail2ban_sip_maxretry']?.value || 3), fail2ban_sip_maxretry: Number(f.elements['fail2ban_sip_maxretry']?.value || 3),
fail2ban_sip_bantime: Number(f.elements['fail2ban_sip_bantime']?.value || 3600), 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 { try {
const res = await apiCall('save_firewall_settings', payload); const res = await apiCall('save_firewall_settings', payload);
showToast(res.message, 'success'); showToast(res.message, 'success');
await loadFirewallStatus(); await loadFirewallStatus();
if (typeof loadState === 'function') {
await loadState();
}
} catch (err) { } catch (err) {
showToast("Erreur application sécurité : " + err.message, 'error'); showToast("Erreur application sécurité : " + err.message, 'error');
} finally { } finally {
@@ -5379,11 +5645,16 @@ function renderFirewall() {
`; `;
} else { } else {
tbody.innerHTML = banned.map(b => { 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 ` return `
<tr class="hover:bg-anthracite-750 transition"> <tr class="hover:bg-anthracite-750 transition">
<td class="px-4 py-3 font-mono font-bold text-white text-sm"> <td class="px-4 py-3 font-mono font-bold text-white text-sm">
<span class="text-rose-400">⛔ ${escapeHtml(b.ip)}</span> <span class="text-rose-400">${adminIcon('ban', 'w-3.5 h-3.5')} ${escapeHtml(b.ip)}</span>
</td> </td>
<td class="px-4 py-3"> <td class="px-4 py-3">
<span class="px-2 py-0.5 rounded-full text-[10px] font-bold ${jailClass}">${escapeHtml(b.jail_label || b.jail)}</span> <span class="px-2 py-0.5 rounded-full text-[10px] font-bold ${jailClass}">${escapeHtml(b.jail_label || b.jail)}</span>
@@ -5424,6 +5695,21 @@ function renderFirewall() {
if (form.elements['fail2ban_whitelist_ips'] && document.activeElement !== form.elements['fail2ban_whitelist_ips']) { if (form.elements['fail2ban_whitelist_ips'] && document.activeElement !== form.elements['fail2ban_whitelist_ips']) {
form.elements['fail2ban_whitelist_ips'].value = s.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;
}
} }
} }
@@ -5999,7 +6285,7 @@ async function checkSystemUpdate(notify = false) {
if (statusText) { if (statusText) {
statusText.className = 'text-amber-400 font-bold'; statusText.className = 'text-amber-400 font-bold';
statusText.innerHTML = `🚀 Nouvelle version disponible : <span class="underline">v${escapeHtml(data.latest_version)}</span> !`; statusText.innerHTML = `${adminIcon('rocket', 'w-3.5 h-3.5 text-indigo-300')} Nouvelle version disponible : <span class="underline">v${escapeHtml(data.latest_version)}</span> !`;
} }
if (btnRun) { if (btnRun) {
btnRun.style.display = 'inline-flex'; btnRun.style.display = 'inline-flex';
@@ -6650,13 +6936,13 @@ async function refreshClusterStatus() {
if (svcKeepalived) { if (svcKeepalived) {
if (!st.keepalived_installed) { if (!st.keepalived_installed) {
svcKeepalived.className = 'font-mono text-amber-400 font-bold'; svcKeepalived.className = 'font-mono text-amber-400 font-bold';
svcKeepalived.textContent = '⚠️ Non installé'; svcKeepalived.innerHTML = adminIcon('warning', 'w-3 h-3 text-amber-400') + ' Non installé';
} else if (st.keepalived_running) { } else if (st.keepalived_running) {
svcKeepalived.className = 'font-mono text-emerald-400 font-bold'; svcKeepalived.className = 'font-mono text-emerald-400 font-bold';
svcKeepalived.textContent = '🟢 Actif (En cours)'; svcKeepalived.innerHTML = '<span class="inline-block w-2 h-2 rounded-full bg-emerald-400 mr-1.5"></span>Actif (En cours)';
} else { } else {
svcKeepalived.className = 'font-mono text-slate-400'; svcKeepalived.className = 'font-mono text-slate-400';
svcKeepalived.textContent = '⚪ Arrêté'; svcKeepalived.innerHTML = '<span class="inline-block w-2 h-2 rounded-full bg-slate-400 mr-1.5"></span>Arrêté';
} }
} }
@@ -6664,13 +6950,13 @@ async function refreshClusterStatus() {
if (svcAsterisk) { if (svcAsterisk) {
if (st.asterisk_healthy) { if (st.asterisk_healthy) {
svcAsterisk.className = 'font-mono text-emerald-400 font-bold'; svcAsterisk.className = 'font-mono text-emerald-400 font-bold';
svcAsterisk.textContent = '🟢 En ligne (Ping Pong OK)'; svcAsterisk.innerHTML = '<span class="inline-block w-2 h-2 rounded-full bg-emerald-400 mr-1.5"></span>En ligne (Ping Pong OK)';
} else if (st.asterisk_running) { } else if (st.asterisk_running) {
svcAsterisk.className = 'font-mono text-amber-400 font-bold'; svcAsterisk.className = 'font-mono text-amber-400 font-bold';
svcAsterisk.textContent = '🟡 En cours (Ping en attente)'; svcAsterisk.innerHTML = '<span class="inline-block w-2 h-2 rounded-full bg-amber-400 mr-1.5"></span>En cours (Ping en attente)';
} else { } else {
svcAsterisk.className = 'font-mono text-red-400 font-bold'; svcAsterisk.className = 'font-mono text-red-400 font-bold';
svcAsterisk.textContent = '🔴 Arrêté'; svcAsterisk.innerHTML = '<span class="inline-block w-2 h-2 rounded-full bg-rose-400 mr-1.5"></span>Arrêté';
} }
} }
@@ -6680,10 +6966,10 @@ async function refreshClusterStatus() {
svcPeer.textContent = 'Non configuré'; svcPeer.textContent = 'Non configuré';
svcPeer.className = 'font-mono text-slate-500'; svcPeer.className = 'font-mono text-slate-500';
} else if (st.peer_reachable) { } else if (st.peer_reachable) {
svcPeer.textContent = `🟢 Joignable (${st.peer_ping_ms !== null ? st.peer_ping_ms.toFixed(1) + 'ms' : 'OK'})`; svcPeer.innerHTML = `<span class="inline-block w-2 h-2 rounded-full bg-emerald-400 mr-1.5"></span>Joignable (${st.peer_ping_ms !== null ? st.peer_ping_ms.toFixed(1) + 'ms' : 'OK'})`;
svcPeer.className = 'font-mono text-emerald-400 font-bold'; svcPeer.className = 'font-mono text-emerald-400 font-bold';
} else { } else {
svcPeer.textContent = '🔴 Injoignable (Timeout)'; svcPeer.innerHTML = '<span class="inline-block w-2 h-2 rounded-full bg-rose-400 mr-1.5"></span>Injoignable (Timeout)';
svcPeer.className = 'font-mono text-red-400 font-bold'; svcPeer.className = 'font-mono text-red-400 font-bold';
} }
} }
@@ -6798,4 +7084,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">${adminIcon('lock', 'w-3.5 h-3.5')} ${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">${adminIcon('globe', 'w-3.5 h-3.5')} ${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 || res.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 || res.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');
}
}
+257 -36
View File
@@ -41,7 +41,7 @@ $isDevEnv = file_exists(__DIR__ . '/../.dev')
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="nexus-app-version" content="<?= htmlspecialchars($appVersion) ?>"> <meta name="nexus-app-version" content="<?= htmlspecialchars($appVersion) ?>">
<title><?= $isDevEnv ? '🛠️ [DEV] ' : '' ?>NexusPBX — Supervision & Téléphonie IP • Powered by Asterisk</title> <title><?= $isDevEnv ? '[DEV] ' : '' ?>NexusPBX — Supervision & Téléphonie IP • Powered by Asterisk</title>
<link rel="icon" type="image/png" href="/assets/favicon.png?v=<?= file_exists(__DIR__ . '/assets/favicon.png') ? filemtime(__DIR__ . '/assets/favicon.png') : 1 ?>"> <link rel="icon" type="image/png" href="/assets/favicon.png?v=<?= file_exists(__DIR__ . '/assets/favicon.png') ? filemtime(__DIR__ . '/assets/favicon.png') : 1 ?>">
<link rel="apple-touch-icon" href="/assets/logo.png?v=<?= file_exists(__DIR__ . '/assets/logo.png') ? filemtime(__DIR__ . '/assets/logo.png') : 1 ?>"> <link rel="apple-touch-icon" href="/assets/logo.png?v=<?= file_exists(__DIR__ . '/assets/logo.png') ? filemtime(__DIR__ . '/assets/logo.png') : 1 ?>">
<script src="https://cdn.tailwindcss.com"></script> <script src="https://cdn.tailwindcss.com"></script>
@@ -338,6 +338,11 @@ $isDevEnv = file_exists(__DIR__ . '/../.dev')
<span>• Annuaire d'Entreprise</span> <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> <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>
<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>
</div> </div>
@@ -532,7 +537,7 @@ $isDevEnv = file_exists(__DIR__ . '/../.dev')
<!-- Badge Notification Mise à Jour Disponible --> <!-- Badge Notification Mise à Jour Disponible -->
<button id="header-update-badge" type="button" onclick="openUpdatePanel()" style="display:none;" <button id="header-update-badge" type="button" onclick="openUpdatePanel()" style="display:none;"
class="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-xl text-xs font-bold bg-amber-500/20 hover:bg-amber-500/30 text-amber-300 border border-amber-500/40 shadow-sm transition animate-pulse" title="Une mise à jour NexusPBX est disponible ! Cliquez pour voir les détails."> class="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-xl text-xs font-bold bg-amber-500/20 hover:bg-amber-500/30 text-amber-300 border border-amber-500/40 shadow-sm transition animate-pulse" title="Une mise à jour NexusPBX est disponible ! Cliquez pour voir les détails.">
<span>🚀</span> <span><svg class="w-4 h-4 shrink-0 inline-block" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M15.59 14.37a6 6 0 01-5.84 7.38v-4.8m5.84-2.58a14.98 14.98 0 006.16-12.12A14.98 14.98 0 009.631 8.41m5.96 5.96a14.926 14.926 0 01-5.841 2.58m-.119-8.54a6 6 0 00-7.381 5.84h4.8m2.581-5.84a14.927 14.927 0 00-2.58 5.84m2.699 2.7c-.103.021-.207.041-.311.06a15.09 15.09 0 01-2.448-2.448 14.9 14.9 0 01.06-.312m-2.24 2.39a4.493 4.493 0 00-1.757 4.306 4.493 4.493 0 004.306-1.758M16.5 9a1.5 1.5 0 11-3 0 1.5 1.5 0 013 0z"/></svg></span>
<span id="header-update-text">Mise à jour dispo</span> <span id="header-update-text">Mise à jour dispo</span>
</button> </button>
@@ -1378,14 +1383,14 @@ $isDevEnv = file_exists(__DIR__ . '/../.dev')
<!-- Bannière d'aide rapide Paging & Intercom 1-à-1 --> <!-- Bannière d'aide rapide Paging & Intercom 1-à-1 -->
<div class="p-4 rounded-2xl bg-anthracite-800/90 border border-purple-500/30 grid grid-cols-1 md:grid-cols-2 gap-4"> <div class="p-4 rounded-2xl bg-anthracite-800/90 border border-purple-500/30 grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="flex items-start gap-3"> <div class="flex items-start gap-3">
<div class="w-8 h-8 rounded-xl bg-purple-500/20 border border-purple-500/40 flex items-center justify-center text-purple-300 shrink-0 font-bold text-sm">📢</div> <div class="w-8 h-8 rounded-xl bg-purple-500/20 border border-purple-500/40 flex items-center justify-center text-purple-300 shrink-0 font-bold text-sm"><svg class="w-4 h-4 shrink-0 inline-block" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M10.34 15.84c-.688-.06-1.386-.09-2.09-.09H7.5a4.5 4.5 0 110-9h.75c.704 0 1.402-.03 2.09-.09m0 9.18c.253.962.584 1.892.985 2.783.247.55.06 1.21-.463 1.511l-.657.38c-.551.318-1.26.117-1.527-.461a20.845 20.845 0 01-1.44-4.282m3.102.069a18.03 18.03 0 01-.59-4.59c0-1.586.205-3.124.59-4.59m0 9.18a23.848 23.848 0 018.835 2.535M10.34 6.66a23.847 23.847 0 008.835-2.535m0 0A23.74 23.74 0 0018.795 3m.38 1.125a23.91 23.91 0 011.014 5.395m-1.014 8.855c-.118.38-.245.754-.38 1.125m.38-1.125a23.91 23.91 0 001.014-5.395m0-3.46c.495.413.811 1.035.811 1.73 0 .695-.316 1.317-.811 1.73m0-3.46a24.347 24.347 0 010 3.46"/></svg></div>
<div class="space-y-0.5"> <div class="space-y-0.5">
<div class="text-xs font-bold text-purple-200">Annonce Générale de Groupe (Paging)</div> <div class="text-xs font-bold text-purple-200">Annonce Générale de Groupe (Paging)</div>
<p class="text-[11px] text-slate-300">Composez le numéro du groupe configuré (ex: <span class="font-mono text-purple-300 font-bold">100</span>). Les téléphones cibles décrochent automatiquement sur haut-parleur après un bip d'avertissement sonore.</p> <p class="text-[11px] text-slate-300">Composez le numéro du groupe configuré (ex: <span class="font-mono text-purple-300 font-bold">100</span>). Les téléphones cibles décrochent automatiquement sur haut-parleur après un bip d'avertissement sonore.</p>
</div> </div>
</div> </div>
<div class="flex items-start gap-3"> <div class="flex items-start gap-3">
<div class="w-8 h-8 rounded-xl bg-indigo-500/20 border border-indigo-500/40 flex items-center justify-center text-indigo-300 shrink-0 font-bold text-sm">📞</div> <div class="w-8 h-8 rounded-xl bg-indigo-500/20 border border-indigo-500/40 flex items-center justify-center text-indigo-300 shrink-0 font-bold text-sm"><svg class="w-4 h-4 shrink-0 inline-block" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M2.25 6.75c0 8.284 6.716 15 15 15h2.25a2.25 2.25 0 002.25-2.25v-1.372c0-.516-.351-.966-.852-1.091l-4.423-1.106c-.44-.11-.902.055-1.173.417l-.97 1.293c-.282.376-.769.542-1.21.38a12.035 12.035 0 01-7.143-7.143c-.162-.441.004-.928.38-1.21l1.293-.97c.363-.271.527-.734.417-1.173L6.963 3.102a1.125 1.125 0 00-1.091-.852H4.5A2.25 2.25 0 002.25 4.5v2.25z"/></svg></div>
<div class="space-y-0.5"> <div class="space-y-0.5">
<div class="text-xs font-bold text-indigo-200">Interphonie Directe 1-à-1 (*80 + poste)</div> <div class="text-xs font-bold text-indigo-200">Interphonie Directe 1-à-1 (*80 + poste)</div>
<p class="text-[11px] text-slate-300">Composez <span class="font-mono text-indigo-300 font-bold">*80</span> suivi du numéro de l'extension (ex: <span class="font-mono text-indigo-300 font-bold">*80101</span>) pour ouvrir directement le haut-parleur et le micro du poste en conversation duplex.</p> <p class="text-[11px] text-slate-300">Composez <span class="font-mono text-indigo-300 font-bold">*80</span> suivi du numéro de l'extension (ex: <span class="font-mono text-indigo-300 font-bold">*80101</span>) pour ouvrir directement le haut-parleur et le micro du poste en conversation duplex.</p>
@@ -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"> <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="asterisk">Téléphonie SIP</option>
<option value="nexus-web">Interface Web Admin</option> <option value="nexus-web">Interface Web Admin</option>
<option value="nexus-ucp">Portail Utilisateur (UCP)</option>
<option value="sshd">Console SSH</option> <option value="sshd">Console SSH</option>
</select> </select>
<button type="submit" <button type="submit"
@@ -2281,6 +2287,55 @@ $isDevEnv = file_exists(__DIR__ . '/../.dev')
</div> </div>
</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) --> <!-- Liste Blanche Permanente (IgnoreIP) -->
<div> <div>
<div class="flex items-center justify-between mb-1"> <div class="flex items-center justify-between mb-1">
@@ -2304,6 +2359,80 @@ $isDevEnv = file_exists(__DIR__ . '/../.dev')
</div> </div>
</section> </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 --> <!-- ONGLET 5 : CODES DE FONCTIONS & SERVICES TÉLÉPHONIQUES -->
<!-- ========================================================= --> <!-- ========================================================= -->
@@ -2353,8 +2482,9 @@ $isDevEnv = file_exists(__DIR__ . '/../.dev')
<div class="bg-anthracite-900 p-2 rounded-lg border border-anthracite-700"><span class="text-slate-400 block text-[10px] uppercase font-sans">Ligne (Line)</span><strong class="text-white">Line 1</strong></div> <div class="bg-anthracite-900 p-2 rounded-lg border border-anthracite-700"><span class="text-slate-400 block text-[10px] uppercase font-sans">Ligne (Line)</span><strong class="text-white">Line 1</strong></div>
<div class="bg-anthracite-900 p-2 rounded-lg border border-indigo-500/40 bg-indigo-950/30"><span class="text-indigo-300 block text-[10px] uppercase font-sans">Extension (Pickup)</span><strong class="text-indigo-300 font-bold">**</strong></div> <div class="bg-anthracite-900 p-2 rounded-lg border border-indigo-500/40 bg-indigo-950/30"><span class="text-indigo-300 block text-[10px] uppercase font-sans">Extension (Pickup)</span><strong class="text-indigo-300 font-bold">**</strong></div>
</div> </div>
<div class="text-[11px] text-slate-400 pt-1"> <div class="text-[11px] text-slate-400 pt-1 flex items-start gap-1.5">
💡 Quand le poste surveillé sonne, sa touche clignote : un simple appui dessus intercepte l'appel immédiatement via le préfixe <code class="text-indigo-300">**</code> ! <svg class="w-3.5 h-3.5 shrink-0 text-amber-400 mt-0.5" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M12 18v-5.25m0 0a6.01 6.01 0 001.5-.189m-1.5.189a6.01 6.01 0 01-1.5-.189m3.75 7.478a12.06 12.06 0 01-4.5 0m3.75 2.383a14.406 14.406 0 01-3 0M14.25 18v-.192c0-.983.658-1.823 1.508-2.316a7.5 7.5 0 10-7.517 0c.85.493 1.509 1.333 1.509 2.316V18"/></svg>
<span>Quand le poste surveillé sonne, sa touche clignote : un simple appui dessus intercepte l'appel immédiatement via le préfixe <code class="text-indigo-300">**</code> !</span>
</div> </div>
</div> </div>
</div> </div>
@@ -2430,7 +2560,7 @@ $isDevEnv = file_exists(__DIR__ . '/../.dev')
<button type="button" onclick="detectPublicIp()" id="btn-detect-ip" <button type="button" onclick="detectPublicIp()" id="btn-detect-ip"
class="text-[11px] text-cyan-400 hover:text-cyan-300 flex items-center gap-1 font-semibold transition"> class="text-[11px] text-cyan-400 hover:text-cyan-300 flex items-center gap-1 font-semibold transition">
<span id="spinner-detect-ip" class="hidden animate-spin">↻</span> <span id="spinner-detect-ip" class="hidden animate-spin">↻</span>
<span>🔍 Détecter l'IP publique</span> <svg class="w-3.5 h-3.5 shrink-0 inline-block" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z"/></svg><span>Détecter l'IP publique</span>
</button> </button>
</div> </div>
<input type="text" id="sip-external-ip" name="external_ip" placeholder="ex: sip.mondomaine.com ou 198.51.100.1" <input type="text" id="sip-external-ip" name="external_ip" placeholder="ex: sip.mondomaine.com ou 198.51.100.1"
@@ -2446,7 +2576,7 @@ $isDevEnv = file_exists(__DIR__ . '/../.dev')
<label class="block text-xs font-semibold text-slate-200">Réseaux Locaux (Local Networks - CIDR) *</label> <label class="block text-xs font-semibold text-slate-200">Réseaux Locaux (Local Networks - CIDR) *</label>
<button type="button" onclick="addLocalNetworkField()" <button type="button" onclick="addLocalNetworkField()"
class="text-[11px] text-emerald-400 hover:text-emerald-300 font-semibold flex items-center gap-1 transition"> class="text-[11px] text-emerald-400 hover:text-emerald-300 font-semibold flex items-center gap-1 transition">
<span>➕ Ajouter un réseau local</span> <svg class="w-3.5 h-3.5 shrink-0 inline-block" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15"/></svg><span>Ajouter un réseau local</span>
</button> </button>
</div> </div>
<div id="local-networks-container" class="space-y-2 max-h-48 overflow-y-auto pr-1"></div> <div id="local-networks-container" class="space-y-2 max-h-48 overflow-y-auto pr-1"></div>
@@ -2939,9 +3069,9 @@ $isDevEnv = file_exists(__DIR__ . '/../.dev')
<input type="text" id="cluster-secret" name="cluster_secret" required <input type="text" id="cluster-secret" name="cluster_secret" required
class="flex-1 px-3 py-2 rounded-xl bg-anthracite-900 border border-anthracite-700 text-xs text-white font-mono focus:outline-none focus:border-cyan-400"> class="flex-1 px-3 py-2 rounded-xl bg-anthracite-900 border border-anthracite-700 text-xs text-white font-mono focus:outline-none focus:border-cyan-400">
<button type="button" onclick="regenerateClusterSecret()" title="Générer un nouveau token" <button type="button" onclick="regenerateClusterSecret()" title="Générer un nouveau token"
class="px-2.5 py-2 rounded-xl bg-anthracite-900 border border-anthracite-700 text-slate-300 hover:text-white transition">🎲</button> class="px-2.5 py-2 rounded-xl bg-anthracite-900 border border-anthracite-700 text-slate-300 hover:text-white transition"><svg class="w-3.5 h-3.5 shrink-0 inline-block" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09z"/></svg></button>
<button type="button" onclick="copyClusterSecret()" title="Copier le token" <button type="button" onclick="copyClusterSecret()" title="Copier le token"
class="px-2.5 py-2 rounded-xl bg-anthracite-900 border border-anthracite-700 text-slate-300 hover:text-white transition">📋</button> class="px-2.5 py-2 rounded-xl bg-anthracite-900 border border-anthracite-700 text-slate-300 hover:text-white transition"><svg class="w-3.5 h-3.5 shrink-0 inline-block" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M15.75 17.25v3.375c0 .621-.504 1.125-1.125 1.125h-9.75a1.125 1.125 0 01-1.125-1.125V7.875c0-.621.504-1.125 1.125-1.125H6.75a9.06 9.06 0 011.5.124m7.5 10.376h3.375c.621 0 1.125-.504 1.125-1.125V11.25c0-4.46-3.243-8.161-7.5-8.876a9.06 9.06 0 00-1.5-.124H9.375c-.621 0-1.125.504-1.125 1.125v3.5m7.5 10.375H9.375a1.125 1.125 0 01-1.125-1.125v-9.25m12 6.625v-1.875a3.375 3.375 0 00-3.375-3.375h-1.5a1.125 1.125 0 01-1.125-1.125v-1.5a3.375 3.375 0 00-3.375-3.375H9.75"/></svg></button>
</div> </div>
</div> </div>
</div> </div>
@@ -3030,7 +3160,7 @@ $isDevEnv = file_exists(__DIR__ . '/../.dev')
</div> </div>
<div class="p-3 rounded-xl bg-indigo-950/40 border border-indigo-500/30 text-[11px] text-indigo-300"> <div class="p-3 rounded-xl bg-indigo-950/40 border border-indigo-500/30 text-[11px] text-indigo-300">
💡 <strong>Conseil d'Architecture :</strong> Vos téléphones IP (Yealink, Groundwire) et vos Trunks SIP doivent pointer sur l'adresse <strong>IP Virtuelle (VIP)</strong>. <svg class="w-4 h-4 shrink-0 inline-block text-amber-400" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M12 18v-5.25m0 0a6.01 6.01 0 001.5-.189m-1.5.189a6.01 6.01 0 01-1.5-.189m3.75 7.478a12.06 12.06 0 01-4.5 0m3.75 2.383a14.406 14.406 0 01-3 0M14.25 18v-.192c0-.983.658-1.823 1.508-2.316a7.5 7.5 0 10-7.517 0c.85.493 1.509 1.333 1.509 2.316V18"/></svg> <strong>Conseil d'Architecture :</strong> Vos téléphones IP (Yealink, Groundwire) et vos Trunks SIP doivent pointer sur l'adresse <strong>IP Virtuelle (VIP)</strong>.
</div> </div>
</div> </div>
</div> </div>
@@ -3052,11 +3182,11 @@ $isDevEnv = file_exists(__DIR__ . '/../.dev')
<div class="flex items-center gap-2 border-b border-anthracite-700 pb-2 mb-4"> <div class="flex items-center gap-2 border-b border-anthracite-700 pb-2 mb-4">
<button type="button" id="tab-btn-ext-general" onclick="switchExtModalTab('general')" <button type="button" id="tab-btn-ext-general" onclick="switchExtModalTab('general')"
class="px-3 py-1.5 rounded-lg text-xs font-bold transition bg-indigo-600 text-white"> class="px-3 py-1.5 rounded-lg text-xs font-bold transition bg-indigo-600 text-white">
⚙️ Général & SIP <span class="inline-flex items-center gap-1.5"><svg class="w-3.5 h-3.5 shrink-0 inline-block" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.324.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 011.37.49l1.296 2.247a1.125 1.125 0 01-.26 1.431l-1.003.827c-.293.24-.438.613-.431.992a6.759 6.759 0 010 .255c-.007.378.138.75.43.99l1.005.828c.424.35.534.954.26 1.43l-1.298 2.247a1.125 1.125 0 01-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.57 6.57 0 01-.22.128c-.331.183-.581.495-.644.869l-.213 1.28c-.09.543-.56.941-1.11.941h-2.594c-.55 0-1.02-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 01-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 01-1.369-.49l-1.297-2.247a1.125 1.125 0 01.26-1.431l1.004-.827c.292-.24.437-.613.43-.992a6.932 6.932 0 010-.255c.007-.378-.138-.75-.43-.99l-1.004-.828a1.125 1.125 0 01-.26-1.43l1.297-2.247a1.125 1.125 0 011.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.087.22-.128.332-.183.582-.495.644-.869l.214-1.281z"/><path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/></svg><span>Général &amp; SIP</span></span>
</button> </button>
<button type="button" id="tab-btn-ext-voicemail" onclick="switchExtModalTab('voicemail')" <button type="button" id="tab-btn-ext-voicemail" onclick="switchExtModalTab('voicemail')"
class="px-3 py-1.5 rounded-lg text-xs font-semibold transition text-slate-300 hover:text-white hover:bg-anthracite-700 flex items-center gap-1.5"> class="px-3 py-1.5 rounded-lg text-xs font-semibold transition text-slate-300 hover:text-white hover:bg-anthracite-700 flex items-center gap-1.5">
<span>📟 Messagerie Vocale</span> <span class="inline-flex items-center gap-1.5"><svg class="w-3.5 h-3.5 shrink-0 inline-block" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M21.75 9v.906a2.25 2.25 0 01-1.183 1.981l-6.478 3.488M2.25 9v.906a2.25 2.25 0 001.183 1.981l6.478 3.488m8.839 2.51l-4.66-2.51m0 0l-1.023-.55a2.25 2.25 0 00-2.134 0l-1.022.55m0 0l-4.661 2.51m16.5 1.615a2.25 2.25 0 01-2.25 2.25h-15a2.25 2.25 0 01-2.25-2.25V8.844a2.25 2.25 0 011.183-1.98l7.5-4.04a2.25 2.25 0 012.134 0l7.5 4.04a2.25 2.25 0 011.183 1.98V19.5z"/></svg><span>Messagerie Vocale</span></span>
<span id="badge-ext-vm-status" class="w-2 h-2 rounded-full bg-slate-500"></span> <span id="badge-ext-vm-status" class="w-2 h-2 rounded-full bg-slate-500"></span>
</button> </button>
</div> </div>
@@ -3134,6 +3264,20 @@ $isDevEnv = file_exists(__DIR__ . '/../.dev')
<p class="text-[11px] text-slate-400">Le mode optionnel négocie le SRTP automatiquement tout en évitant les échecs d'appels si le poste IP a le SRTP désactivé.</p> <p class="text-[11px] text-slate-400">Le mode optionnel négocie le SRTP automatiquement tout en évitant les échecs d'appels si le poste IP a le SRTP désactivé.</p>
</div> </div>
<label class="flex items-start gap-3 p-3.5 rounded-xl bg-anthracite-950 border border-anthracite-700 hover:border-cyan-500/50 cursor-pointer transition">
<input type="checkbox" name="groundwire_enabled" id="ext-groundwire-enabled" value="1"
class="mt-0.5 rounded bg-anthracite-900 border-anthracite-600 text-cyan-500 focus:ring-cyan-500">
<div class="leading-snug">
<div class="text-xs font-bold text-white flex items-center gap-2">
<span>GroundWire XML</span>
<span class="px-1.5 py-0.5 rounded text-[10px] font-semibold bg-cyan-500/15 text-cyan-300 border border-cyan-500/30">Softphone Mobile UCP</span>
</div>
<div class="text-[11px] text-slate-400 mt-0.5">
Affiche les boutons d'auto-configuration Acrobits GroundWire (lien direct mobile + téléchargement XML) sur ce poste dans l'UCP.
</div>
</div>
</label>
<div class="p-3.5 rounded-xl bg-anthracite-950 border border-anthracite-700 space-y-2"> <div class="p-3.5 rounded-xl bg-anthracite-950 border border-anthracite-700 space-y-2">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<span class="text-xs font-bold text-cyan-300">Appartenance aux Groupes d'Extensions (Sites & Droits)</span> <span class="text-xs font-bold text-cyan-300">Appartenance aux Groupes d'Extensions (Sites & Droits)</span>
@@ -3234,7 +3378,7 @@ $isDevEnv = file_exists(__DIR__ . '/../.dev')
<!-- Indicateur MWI Yealink --> <!-- Indicateur MWI Yealink -->
<div class="p-3 rounded-xl bg-emerald-500/10 border border-emerald-500/30 flex items-start gap-2.5"> <div class="p-3 rounded-xl bg-emerald-500/10 border border-emerald-500/30 flex items-start gap-2.5">
<span class="text-base">💡</span> <svg class="w-4 h-4 shrink-0 inline-block text-amber-400" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M12 18v-5.25m0 0a6.01 6.01 0 001.5-.189m-1.5.189a6.01 6.01 0 01-1.5-.189m3.75 7.478a12.06 12.06 0 01-4.5 0m3.75 2.383a14.406 14.406 0 01-3 0M14.25 18v-.192c0-.983.658-1.823 1.508-2.316a7.5 7.5 0 10-7.517 0c.85.493 1.509 1.333 1.509 2.316V18"/></svg>
<div class="text-xs text-emerald-200"> <div class="text-xs text-emerald-200">
<strong class="font-bold">Notification MWI automatique :</strong> <strong class="font-bold">Notification MWI automatique :</strong>
Le voyant LED ou l'enveloppe clignoteront sur vos téléphones IP (Yealink, Fanvil, Grandstream, Snom...) dès qu'un message vocal est reçu. Le voyant LED ou l'enveloppe clignoteront sur vos téléphones IP (Yealink, Fanvil, Grandstream, Snom...) dès qu'un message vocal est reçu.
@@ -3568,11 +3712,11 @@ $isDevEnv = file_exists(__DIR__ . '/../.dev')
</label> </label>
<label class="cursor-pointer"> <label class="cursor-pointer">
<input type="radio" name="route_type" value="emergency" class="peer hidden"> <input type="radio" name="route_type" value="emergency" class="peer hidden">
<span class="px-2.5 py-1 rounded-md text-xs font-semibold text-slate-300 peer-checked:bg-emerald-600 peer-checked:text-white transition inline-block">🚑 Urgence</span> <span class="px-2.5 py-1 rounded-md text-xs font-semibold text-slate-300 peer-checked:bg-emerald-600 peer-checked:text-white transition inline-block"><span class="inline-flex items-center gap-1"><svg class="w-3.5 h-3.5 shrink-0 inline-block text-rose-400" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m0-10.036A11.959 11.959 0 013.598 6 11.99 11.99 0 003 9.75c0 5.592 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.57-.598-3.75h-.152c-3.196 0-6.1-1.249-8.25-3.286zm0 13.036h.008v.008H12v-.008z"/></svg><span>Urgence</span></span></span>
</label> </label>
<label class="cursor-pointer"> <label class="cursor-pointer">
<input type="radio" name="route_type" value="intracompany" class="peer hidden"> <input type="radio" name="route_type" value="intracompany" class="peer hidden">
<span class="px-2.5 py-1 rounded-md text-xs font-semibold text-slate-300 peer-checked:bg-cyan-600 peer-checked:text-white transition inline-block">🏢 Intra-Company</span> <span class="px-2.5 py-1 rounded-md text-xs font-semibold text-slate-300 peer-checked:bg-cyan-600 peer-checked:text-white transition inline-flex items-center gap-1"><svg class="w-3.5 h-3.5 shrink-0 text-cyan-400" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M3.75 21h16.5M4.5 3h15M5.25 3v18m13.5-18v18M9 6.75h1.5m-1.5 3h1.5m-1.5 3h1.5m3-6H15m-1.5 3H15m-1.5 3H15M9 21v-3.375c0-.621.504-1.125 1.125-1.125h3.75c.621 0 1.125.504 1.125 1.125V21"/></svg><span>Intra-Company</span></span>
</label> </label>
</div> </div>
</div> </div>
@@ -3614,11 +3758,11 @@ $isDevEnv = file_exists(__DIR__ . '/../.dev')
</label> </label>
</div> </div>
<div> <div>
<label class="block text-xs font-medium text-slate-200 mb-1">🎵 Musique d'attente (MoH)</label> <label class="block text-xs font-medium text-slate-200 mb-1"><span class="inline-flex items-center gap-1.5"><svg class="w-3.5 h-3.5 shrink-0 inline-block text-sky-400" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M9 9l10.5-3m0 6.553v3.75a2.25 2.25 0 01-1.632 2.163l-1.32.377a1.803 1.803 0 11-.99-3.467l2.31-.66a2.25 2.25 0 001.632-2.163zm0 0V2.25L9 5.25v10.303m0 0v3.75a2.25 2.25 0 01-1.632 2.163l-1.32.377a1.803 1.803 0 01-.99-3.467l2.31-.66A2.25 2.25 0 009 15.553z"/></svg><span>Musique d'attente (MoH)</span></span></label>
<select id="ob-musicclass-select" name="moh_class" class="w-full px-3 py-2 rounded-lg bg-anthracite-900 border border-anthracite-700 text-xs text-white"></select> <select id="ob-musicclass-select" name="moh_class" class="w-full px-3 py-2 rounded-lg bg-anthracite-900 border border-anthracite-700 text-xs text-white"></select>
</div> </div>
<div> <div>
<label class="block text-xs font-medium text-slate-200 mb-1">📢 Si Trunks indisponibles <span class="text-slate-400">(Échec / Congestion)</span></label> <label class="block text-xs font-medium text-slate-200 mb-1"><span class="inline-flex items-center gap-1.5"><svg class="w-4 h-4 shrink-0 inline-block" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M10.34 15.84c-.688-.06-1.386-.09-2.09-.09H7.5a4.5 4.5 0 110-9h.75c.704 0 1.402-.03 2.09-.09m0 9.18c.253.962.584 1.892.985 2.783.247.55.06 1.21-.463 1.511l-.657.38c-.551.318-1.26.117-1.527-.461a20.845 20.845 0 01-1.44-4.282m3.102.069a18.03 18.03 0 01-.59-4.59c0-1.586.205-3.124.59-4.59m0 9.18a23.848 23.848 0 018.835 2.535M10.34 6.66a23.847 23.847 0 008.835-2.535m0 0A23.74 23.74 0 0018.795 3m.38 1.125a23.91 23.91 0 011.014 5.395m-1.014 8.855c-.118.38-.245.754-.38 1.125m.38-1.125a23.91 23.91 0 001.014-5.395m0-3.46c.495.413.811 1.035.811 1.73 0 .695-.316 1.317-.811 1.73m0-3.46a24.347 24.347 0 010 3.46"/></svg><span>Si Trunks indisponibles <span class="text-slate-400">(Échec / Congestion)</span></label>
<select id="ob-congestion-dest-select" name="congestion_dest" class="w-full px-3 py-2 rounded-lg bg-anthracite-900 border border-anthracite-700 text-xs text-white"></select> <select id="ob-congestion-dest-select" name="congestion_dest" class="w-full px-3 py-2 rounded-lg bg-anthracite-900 border border-anthracite-700 text-xs text-white"></select>
</div> </div>
</div> </div>
@@ -3629,7 +3773,7 @@ $isDevEnv = file_exists(__DIR__ . '/../.dev')
<div class="p-4 rounded-xl bg-anthracite-950 border border-emerald-500/30 space-y-3"> <div class="p-4 rounded-xl bg-anthracite-950 border border-emerald-500/30 space-y-3">
<div class="flex flex-wrap items-center justify-between gap-2"> <div class="flex flex-wrap items-center justify-between gap-2">
<div> <div>
<div class="text-xs font-bold uppercase tracking-wider text-emerald-300">🪄 Masques de numérotation pré-définis (1 clic)</div> <div class="text-xs font-bold uppercase tracking-wider text-emerald-300"><span class="inline-flex items-center gap-1.5"><svg class="w-3.5 h-3.5 shrink-0 inline-block" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09z"/></svg><span>Masques de numérotation pré-définis (1 clic)</span></span></div>
<p class="text-[11px] text-slate-300">Ajoutez instantanément les masques standards ou créez vos propres règles <code class="text-indigo-300">(prepend) + préfixe | [match pattern / ID appelant]</code>.</p> <p class="text-[11px] text-slate-300">Ajoutez instantanément les masques standards ou créez vos propres règles <code class="text-indigo-300">(prepend) + préfixe | [match pattern / ID appelant]</code>.</p>
</div> </div>
<button type="button" onclick="addOutboundPatternRow()" <button type="button" onclick="addOutboundPatternRow()"
@@ -3645,19 +3789,19 @@ $isDevEnv = file_exists(__DIR__ . '/../.dev')
</button> </button>
<button type="button" onclick="applyOutboundPatternPreset('fr_free')" <button type="button" onclick="applyOutboundPatternPreset('fr_free')"
class="px-3 py-1.5 rounded-lg bg-anthracite-900 hover:bg-emerald-500/20 border border-anthracite-600 hover:border-emerald-500/40 text-xs font-semibold text-cyan-300 transition"> class="px-3 py-1.5 rounded-lg bg-anthracite-900 hover:bg-emerald-500/20 border border-anthracite-600 hover:border-emerald-500/40 text-xs font-semibold text-cyan-300 transition">
🟢 Numéros Verts / Gratuits (080[0-9]XXXXXX) <span class="inline-block w-2 h-2 rounded-full bg-emerald-400 mr-1.5"></span>Numéros Verts / Gratuits (080[0-9]XXXXXX)
</button> </button>
<button type="button" onclick="applyOutboundPatternPreset('fr_emergency')" <button type="button" onclick="applyOutboundPatternPreset('fr_emergency')"
class="px-3 py-1.5 rounded-lg bg-anthracite-900 hover:bg-amber-500/20 border border-anthracite-600 hover:border-amber-500/40 text-xs font-semibold text-amber-300 transition"> class="px-3 py-1.5 rounded-lg bg-anthracite-900 hover:bg-amber-500/20 border border-anthracite-600 hover:border-amber-500/40 text-xs font-semibold text-amber-300 transition">
🚑 Numéros d'Urgence (15, 17, 18, 112, 114) <span class="inline-flex items-center gap-1.5"><svg class="w-3.5 h-3.5 shrink-0 inline-block text-rose-400" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m0-10.036A11.959 11.959 0 013.598 6 11.99 11.99 0 003 9.75c0 5.592 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.57-.598-3.75h-.152c-3.196 0-6.1-1.249-8.25-3.286zm0 13.036h.008v.008H12v-.008z"/></svg><span>Numéros d'Urgence (15, 17, 18, 112, 114)</span></span>
</button> </button>
<button type="button" onclick="applyOutboundPatternPreset('international')" <button type="button" onclick="applyOutboundPatternPreset('international')"
class="px-3 py-1.5 rounded-lg bg-anthracite-900 hover:bg-indigo-500/20 border border-anthracite-600 hover:border-indigo-500/40 text-xs font-semibold text-indigo-300 transition"> class="px-3 py-1.5 rounded-lg bg-anthracite-900 hover:bg-indigo-500/20 border border-anthracite-600 hover:border-indigo-500/40 text-xs font-semibold text-indigo-300 transition">
🌍 International (00. & +X.) <span class="inline-flex items-center gap-1.5"><svg class="w-3.5 h-3.5 shrink-0 inline-block text-cyan-400" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M12 21a9.004 9.004 0 008.716-6.747M12 21a9.004 9.004 0 01-8.716-6.747M12 21c2.485 0 4.5-4.03 4.5-9S14.485 3 12 3m0 18c-2.485 0-4.5-4.03-4.5-9S9.515 3 12 3"/></svg><span>International (00. &amp; +X.)</span></span>
</button> </button>
<button type="button" onclick="applyOutboundPatternPreset('all')" <button type="button" onclick="applyOutboundPatternPreset('all')"
class="px-3 py-1.5 rounded-lg bg-anthracite-900 hover:bg-purple-500/20 border border-anthracite-600 hover:border-purple-500/40 text-xs font-semibold text-purple-300 transition"> class="px-3 py-1.5 rounded-lg bg-anthracite-900 hover:bg-purple-500/20 border border-anthracite-600 hover:border-purple-500/40 text-xs font-semibold text-purple-300 transition inline-flex items-center gap-1.5">
⭐ Tout autoriser (X.) <svg class="w-3.5 h-3.5 shrink-0 text-purple-400" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M11.48 3.499a.562.562 0 011.04 0l2.125 5.111a.563.563 0 00.475.345l5.518.442c.499.04.701.663.321.988l-4.204 3.602a.563.563 0 00-.182.557l1.285 5.385a.562.562 0 01-.84.61l-4.725-2.885a.563.563 0 00-.586 0L6.982 20.54a.562.562 0 01-.84-.61l1.285-5.386a.562.562 0 00-.182-.557l-4.204-3.602a.563.563 0 01.321-.988l5.518-.442a.563.563 0 00.475-.345L11.48 3.5z"/></svg><span>Tout autoriser (X.)</span>
</button> </button>
</div> </div>
</div> </div>
@@ -3708,7 +3852,7 @@ $isDevEnv = file_exists(__DIR__ . '/../.dev')
<div class="p-4 rounded-xl bg-anthracite-950 border border-cyan-500/40 space-y-3"> <div class="p-4 rounded-xl bg-anthracite-950 border border-cyan-500/40 space-y-3">
<div class="flex flex-wrap items-center justify-between gap-2"> <div class="flex flex-wrap items-center justify-between gap-2">
<div> <div>
<div class="text-xs font-bold uppercase tracking-wider text-cyan-300">👥 Groupes d'Extensions Autorisés (ex: Groupe Nice, Groupe Paris)</div> <div class="text-xs font-bold uppercase tracking-wider text-cyan-300"><span class="inline-flex items-center gap-1.5"><svg class="w-3.5 h-3.5 shrink-0 inline-block text-cyan-400" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M18 18.72a9.094 9.094 0 003.741-.479 3 3 0 00-4.682-2.72m.94 3.198l.001.031c0 .225-.012.447-.037.666A11.944 11.944 0 0112 21c-2.17 0-4.207-.576-5.963-1.584A6.062 6.062 0 016 18.719m12 0a5.971 5.971 0 00-.941-3.197m0 0A5.995 5.995 0 0012 12.75a5.995 5.995 0 00-5.058 2.772m0 0a3 3 0 00-4.681 2.72 8.986 8.986 0 003.74.477m.94-3.197a5.971 5.971 0 00-.94 3.197M15 6.75a3 3 0 11-6 0 3 3 0 016 0z"/></svg><span>Groupes d'Extensions Autorisés (ex: Groupe Nice, Groupe Paris)</div>
<p class="text-[11px] text-slate-300">Tous les postes rattachés à un groupe d'extensions coché ci-dessous auront le droit d'appeler via cette route sortante.</p> <p class="text-[11px] text-slate-300">Tous les postes rattachés à un groupe d'extensions coché ci-dessous auront le droit d'appeler via cette route sortante.</p>
</div> </div>
<button type="button" onclick="closeModal('modal-outbound'); switchTab('extgroups'); openExtGroupModal();" <button type="button" onclick="closeModal('modal-outbound'); switchTab('extgroups'); openExtGroupModal();"
@@ -3723,7 +3867,7 @@ $isDevEnv = file_exists(__DIR__ . '/../.dev')
<div class="p-4 rounded-xl bg-anthracite-950 border border-anthracite-700 space-y-3"> <div class="p-4 rounded-xl bg-anthracite-950 border border-anthracite-700 space-y-3">
<div class="flex flex-wrap items-center justify-between gap-2"> <div class="flex flex-wrap items-center justify-between gap-2">
<div> <div>
<div class="text-xs font-bold uppercase tracking-wider text-emerald-300">📱 Extension Routing (Postes individuels Autorisés ↔ Bloqués)</div> <div class="text-xs font-bold uppercase tracking-wider text-emerald-300"><span class="inline-flex items-center gap-1.5"><svg class="w-3.5 h-3.5 shrink-0 inline-block text-emerald-400" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M10.5 1.5H8.25A2.25 2.25 0 006 3.75v16.5a2.25 2.25 0 002.25 2.25h7.5A2.25 2.25 0 0018 20.25V3.75a2.25 2.25 0 00-2.25-2.25H13.5m-3 0V3h3V1.5m-3 0h3m-3 18.75h3"/></svg><span>Extension Routing (Postes individuels Autorisés ↔ Bloqués)</div>
<p class="text-[11px] text-slate-300">Cliquez sur un poste pour le déplacer entre <strong>Allowed Extensions</strong> et <strong>Blocked Extensions</strong>. Si un poste est bloqué ici, l'appel passera automatiquement à la Route Sortante suivante.</p> <p class="text-[11px] text-slate-300">Cliquez sur un poste pour le déplacer entre <strong>Allowed Extensions</strong> et <strong>Blocked Extensions</strong>. Si un poste est bloqué ici, l'appel passera automatiquement à la Route Sortante suivante.</p>
</div> </div>
</div> </div>
@@ -3795,7 +3939,7 @@ $isDevEnv = file_exists(__DIR__ . '/../.dev')
Trunk avec Enregistrement (REGISTER + Auth) Trunk avec Enregistrement (REGISTER + Auth)
</button> </button>
<button type="button" onclick="applyTrunkArchitecture('gateway_receive')" class="px-2.5 py-1 rounded-lg bg-anthracite-950 hover:bg-emerald-600/30 border border-emerald-500/50 text-emerald-300 font-medium transition flex items-center gap-1"> <button type="button" onclick="applyTrunkArchitecture('gateway_receive')" class="px-2.5 py-1 rounded-lg bg-anthracite-950 hover:bg-emerald-600/30 border border-emerald-500/50 text-emerald-300 font-medium transition flex items-center gap-1">
<span>📟 Passerelle FXO / Ligne Box (Grandstream, Cisco SPA, AudioCodes)</span> <span class="inline-flex items-center gap-1.5"><svg class="w-3.5 h-3.5 shrink-0 inline-block" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M21.75 9v.906a2.25 2.25 0 01-1.183 1.981l-6.478 3.488M2.25 9v.906a2.25 2.25 0 001.183 1.981l6.478 3.488m8.839 2.51l-4.66-2.51m0 0l-1.023-.55a2.25 2.25 0 00-2.134 0l-1.022.55m0 0l-4.661 2.51m16.5 1.615a2.25 2.25 0 01-2.25 2.25h-15a2.25 2.25 0 01-2.25-2.25V8.844a2.25 2.25 0 011.183-1.98l7.5-4.04a2.25 2.25 0 012.134 0l7.5 4.04a2.25 2.25 0 011.183 1.98V19.5z"/></svg><span>Passerelle FXO / Ligne Box (Grandstream, Cisco SPA, AudioCodes)</span>
</button> </button>
<button type="button" onclick="applyTrunkArchitecture('ip_peering')" class="px-2.5 py-1 rounded-lg bg-anthracite-950 hover:bg-indigo-600/30 border border-anthracite-600 text-purple-300 font-medium transition"> <button type="button" onclick="applyTrunkArchitecture('ip_peering')" class="px-2.5 py-1 rounded-lg bg-anthracite-950 hover:bg-indigo-600/30 border border-anthracite-600 text-purple-300 font-medium transition">
Peering IP / SBC / Passerelle (Sans REGISTER) Peering IP / SBC / Passerelle (Sans REGISTER)
@@ -3901,7 +4045,7 @@ $isDevEnv = file_exists(__DIR__ . '/../.dev')
<!-- Guide interactif d'aide pour Passerelles FXO (Grandstream, Cisco SPA...) --> <!-- Guide interactif d'aide pour Passerelles FXO (Grandstream, Cisco SPA...) -->
<div id="trunk-receive-guide" class="hidden p-3.5 rounded-xl bg-emerald-950/40 border border-emerald-500/40 text-xs text-slate-200 space-y-2"> <div id="trunk-receive-guide" class="hidden p-3.5 rounded-xl bg-emerald-950/40 border border-emerald-500/40 text-xs text-slate-200 space-y-2">
<div class="flex items-center gap-2 font-bold text-emerald-300 text-xs"> <div class="flex items-center gap-2 font-bold text-emerald-300 text-xs">
<span>💡 Guide Rapide : Paramétrage d'une passerelle FXO (Grandstream HT813/HT503, Cisco SPA3102...)</span> <span class="inline-flex items-center gap-1.5"><svg class="w-4 h-4 shrink-0 inline-block text-amber-400" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M12 18v-5.25m0 0a6.01 6.01 0 001.5-.189m-1.5.189a6.01 6.01 0 01-1.5-.189m3.75 7.478a12.06 12.06 0 01-4.5 0m3.75 2.383a14.406 14.406 0 01-3 0M14.25 18v-.192c0-.983.658-1.823 1.508-2.316a7.5 7.5 0 10-7.517 0c.85.493 1.509 1.333 1.509 2.316V18"/></svg> Guide Rapide : Paramétrage d'une passerelle FXO (Grandstream HT813/HT503, Cisco SPA3102...)</span>
</div> </div>
<p class="text-[11px] text-slate-300 leading-relaxed"> <p class="text-[11px] text-slate-300 leading-relaxed">
Dans l'interface web de votre passerelle (onglet <strong>FXO Port</strong> Grandstream ou <strong>Line 1/PSTN</strong> Cisco SPA relié à la prise téléphonique d'une Box Internet / modem FAI / ligne analogique) : Dans l'interface web de votre passerelle (onglet <strong>FXO Port</strong> Grandstream ou <strong>Line 1/PSTN</strong> Cisco SPA relié à la prise téléphonique d'une Box Internet / modem FAI / ligne analogique) :
@@ -4038,7 +4182,7 @@ $isDevEnv = file_exists(__DIR__ . '/../.dev')
<div class="p-3.5 rounded-xl bg-anthracite-950 border border-anthracite-700/80 flex flex-col sm:flex-row sm:items-center justify-between gap-3"> <div class="p-3.5 rounded-xl bg-anthracite-950 border border-anthracite-700/80 flex flex-col sm:flex-row sm:items-center justify-between gap-3">
<div class="max-w-xl"> <div class="max-w-xl">
<label class="block text-xs font-bold text-white mb-0.5 flex items-center gap-1.5"> <label class="block text-xs font-bold text-white mb-0.5 flex items-center gap-1.5">
<span>📞 Options d'appel Asterisk (Asterisk Trunk Dial Options)</span> <span class="inline-flex items-center gap-1.5"><svg class="w-4 h-4 shrink-0 inline-block" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M2.25 6.75c0 8.284 6.716 15 15 15h2.25a2.25 2.25 0 002.25-2.25v-1.372c0-.516-.351-.966-.852-1.091l-4.423-1.106c-.44-.11-.902.055-1.173.417l-.97 1.293c-.282.376-.769.542-1.21.38a12.035 12.035 0 01-7.143-7.143c-.162-.441.004-.928.38-1.21l1.293-.97c.363-.271.527-.734.417-1.173L6.963 3.102a1.125 1.125 0 00-1.091-.852H4.5A2.25 2.25 0 002.25 4.5v2.25z"/></svg><span>Options d'appel Asterisk (Asterisk Trunk Dial Options)</span>
<span class="px-1.5 py-0.2 rounded bg-indigo-500/20 text-indigo-300 text-[10px] font-mono font-semibold">FreePBX Dial Options</span> <span class="px-1.5 py-0.2 rounded bg-indigo-500/20 text-indigo-300 text-[10px] font-mono font-semibold">FreePBX Dial Options</span>
</label> </label>
<p class="text-[11px] text-slate-300"> <p class="text-[11px] text-slate-300">
@@ -4073,7 +4217,7 @@ $isDevEnv = file_exists(__DIR__ . '/../.dev')
<div class="min-w-[280px]"> <div class="min-w-[280px]">
<select name="moh_sound_id" id="trunk-moh-sound-id" <select name="moh_sound_id" id="trunk-moh-sound-id"
class="w-full px-3 py-2 rounded-xl bg-anthracite-900 border border-anthracite-700 text-xs text-white focus:outline-none focus:border-amber-500 font-medium"> class="w-full px-3 py-2 rounded-xl bg-anthracite-900 border border-anthracite-700 text-xs text-white focus:outline-none focus:border-amber-500 font-medium">
<option value="0">🌐 Hériter de la musique générale par défaut</option> <option value="0">Hériter de la musique générale par défaut</option>
</select> </select>
</div> </div>
</div> </div>
@@ -4082,7 +4226,7 @@ $isDevEnv = file_exists(__DIR__ . '/../.dev')
<div class="p-3.5 rounded-xl bg-anthracite-950 border border-anthracite-700/80 flex flex-col sm:flex-row sm:items-center justify-between gap-3"> <div class="p-3.5 rounded-xl bg-anthracite-950 border border-anthracite-700/80 flex flex-col sm:flex-row sm:items-center justify-between gap-3">
<div class="max-w-xl"> <div class="max-w-xl">
<label class="block text-xs font-bold text-white mb-0.5 flex items-center gap-1.5"> <label class="block text-xs font-bold text-white mb-0.5 flex items-center gap-1.5">
<span>🏷️ Préfixe Nom Appelant (CID Name Prefix)</span> <span class="inline-flex items-center gap-1.5"><svg class="w-3.5 h-3.5 shrink-0 inline-block text-amber-400" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M9.568 3H5.25A2.25 2.25 0 003 5.25v4.318c0 .597.237 1.17.659 1.591l9.581 9.581c.699.699 1.78.872 2.607.33a18.095 18.095 0 005.223-5.223c.542-.827.369-1.908-.33-2.607L11.16 3.66A2.25 2.25 0 009.568 3z"/></svg><span>Préfixe Nom Appelant (CID Name Prefix)</span>
<span class="px-1.5 py-0.2 rounded bg-indigo-500/20 text-indigo-300 text-[10px] font-mono font-semibold">FreePBX CID Prefix</span> <span class="px-1.5 py-0.2 rounded bg-indigo-500/20 text-indigo-300 text-[10px] font-mono font-semibold">FreePBX CID Prefix</span>
</label> </label>
<p class="text-[11px] text-slate-300"> <p class="text-[11px] text-slate-300">
@@ -4108,7 +4252,7 @@ $isDevEnv = file_exists(__DIR__ . '/../.dev')
<div class="flex flex-wrap items-center gap-2"> <div class="flex flex-wrap items-center gap-2">
<button type="button" onclick="addArcepSpamBlockPreset()" <button type="button" onclick="addArcepSpamBlockPreset()"
class="px-3 py-1.5 rounded-xl bg-amber-500/20 hover:bg-amber-500/30 border border-amber-500/40 text-xs font-semibold text-amber-300 transition"> class="px-3 py-1.5 rounded-xl bg-amber-500/20 hover:bg-amber-500/30 border border-amber-500/40 text-xs font-semibold text-amber-300 transition">
🛡️ + Ajouter Pack Blocage Démarchage (0162, 0163, 0270, 0377, 0424, 0568, 0948...) <span class="inline-flex items-center gap-1.5"><svg class="w-3.5 h-3.5 shrink-0 inline-block" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75L11.25 15 15 9.75m-3-7.036A11.959 11.959 0 013.598 6 11.99 11.99 0 003 9.749c0 5.592 3.824 10.29 9 11.623 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.571-.598-3.751h-.152c-3.196 0-6.1-1.248-8.25-3.285z"/></svg><span>+ Ajouter Pack Blocage Démarchage</span></span> (0162, 0163, 0270, 0377, 0424, 0568, 0948...)
</button> </button>
<button type="button" onclick="addTrunkInboundRouteRow('Any', 'Any', 'Nouvelle règle', 'ext-101', true)" <button type="button" onclick="addTrunkInboundRouteRow('Any', 'Any', 'Nouvelle règle', 'ext-101', true)"
class="px-3.5 py-1.5 rounded-xl bg-indigo-600 hover:bg-indigo-500 text-xs font-semibold text-white shadow-md shadow-indigo-600/20 transition"> class="px-3.5 py-1.5 rounded-xl bg-indigo-600 hover:bg-indigo-500 text-xs font-semibold text-white shadow-md shadow-indigo-600/20 transition">
@@ -4120,7 +4264,7 @@ $isDevEnv = file_exists(__DIR__ . '/../.dev')
<!-- Barre de filtre rapide dans les règles --> <!-- Barre de filtre rapide dans les règles -->
<div class="flex flex-wrap items-center justify-between gap-2 pt-1"> <div class="flex flex-wrap items-center justify-between gap-2 pt-1">
<input type="text" id="inbound-routes-filter" oninput="filterInboundRoutesRows(this.value)" <input type="text" id="inbound-routes-filter" oninput="filterInboundRoutesRows(this.value)"
placeholder="🔍 Filtrer vos règles (ex: 0162, Block, VIP, Issy, Occupation...)" placeholder="Filtrer vos règles (ex: 0162, Block, VIP, Issy, Occupation...)"
class="w-full sm:w-96 px-3 py-1.5 rounded-lg bg-anthracite-900 border border-anthracite-700 text-xs text-white"> class="w-full sm:w-96 px-3 py-1.5 rounded-lg bg-anthracite-900 border border-anthracite-700 text-xs text-white">
<div class="text-[11px] text-slate-400"> <div class="text-[11px] text-slate-400">
Syntaxe Asterisk : <code class="text-indigo-300">Any</code> = Tous • <code class="text-indigo-300">_0162XXXXXX</code> = Plage 0162 • <code class="text-indigo-300">0612345678</code> = Numéro exact Syntaxe Asterisk : <code class="text-indigo-300">Any</code> = Tous • <code class="text-indigo-300">_0162XXXXXX</code> = Plage 0162 • <code class="text-indigo-300">0612345678</code> = Numéro exact
@@ -4508,7 +4652,7 @@ $isDevEnv = file_exists(__DIR__ . '/../.dev')
<!-- Colonne Choix Invalide --> <!-- Colonne Choix Invalide -->
<div class="p-3.5 rounded-xl bg-anthracite-950/60 border border-anthracite-700 space-y-3"> <div class="p-3.5 rounded-xl bg-anthracite-950/60 border border-anthracite-700 space-y-3">
<div class="text-xs font-bold text-white flex items-center gap-1.5"> <div class="text-xs font-bold text-white flex items-center gap-1.5">
<span class="text-red-400">⚠️</span> En cas de Touche Invalide <span class="inline-flex items-center gap-1.5"><svg class="w-3.5 h-3.5 shrink-0 inline-block text-amber-400" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z"/></svg><span>En cas de Touche Invalide</span></span>
</div> </div>
<div> <div>
@@ -4535,7 +4679,7 @@ $isDevEnv = file_exists(__DIR__ . '/../.dev')
<!-- Colonne Expiration / Timeout --> <!-- Colonne Expiration / Timeout -->
<div class="p-3.5 rounded-xl bg-anthracite-950/60 border border-anthracite-700 space-y-3"> <div class="p-3.5 rounded-xl bg-anthracite-950/60 border border-anthracite-700 space-y-3">
<div class="text-xs font-bold text-white flex items-center gap-1.5"> <div class="text-xs font-bold text-white flex items-center gap-1.5">
<span class="text-amber-400">⏱️</span> En cas de Timeout (Aucune touche pressée) <svg class="w-4 h-4 shrink-0 text-amber-400" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z"/></svg> En cas de Timeout (Aucune touche pressée)
</div> </div>
<div class="grid grid-cols-2 gap-2"> <div class="grid grid-cols-2 gap-2">
@@ -5421,7 +5565,7 @@ $isDevEnv = file_exists(__DIR__ . '/../.dev')
</p> </p>
<div id="reboot-modal-call-details" class="p-2.5 rounded-lg bg-black/50 border border-rose-500/40 text-xs font-mono text-rose-300"></div> <div id="reboot-modal-call-details" class="p-2.5 rounded-lg bg-black/50 border border-rose-500/40 text-xs font-mono text-rose-300"></div>
<div class="p-3 rounded-lg bg-rose-950/60 border border-rose-500/40 text-xs font-semibold text-rose-200 flex items-start gap-2"> <div class="p-3 rounded-lg bg-rose-950/60 border border-rose-500/40 text-xs font-semibold text-rose-200 flex items-start gap-2">
<span class="text-base leading-none">⚠️</span> <svg class="w-3.5 h-3.5 shrink-0 inline-block text-amber-400" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z"/></svg>
<span>Si vous confirmez le redémarrage, <strong>la conversation téléphonique sera immédiatement coupée</strong> pour votre interlocuteur !</span> <span>Si vous confirmez le redémarrage, <strong>la conversation téléphonique sera immédiatement coupée</strong> pour votre interlocuteur !</span>
</div> </div>
</div> </div>
@@ -5458,6 +5602,83 @@ $isDevEnv = file_exists(__DIR__ . '/../.dev')
</div> </div>
</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> <script src="/assets/app.js?v=<?= filemtime(__DIR__ . '/assets/app.js') ?>"></script>
</body> </body>
</html> </html>
+12 -1
View File
@@ -19,6 +19,8 @@ switch ($cmd) {
echo "• Pare-feu & Filtrage : " . ($settings['firewall_enabled'] ? "\033[32mACTIF\033[0m" : "\033[33mINACTIF\033[0m") . "\n"; 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 "• 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"; 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"; echo "• Total IPs Bannies : " . $st['total_banned'] . "\n";
if (!empty($st['banned_ips'])) { if (!empty($st['banned_ips'])) {
echo "\nListe des IPs bannies :\n"; echo "\nListe des IPs bannies :\n";
@@ -72,8 +74,17 @@ switch ($cmd) {
echo ($res['success'] ? "\033[32m[OK]\033[0m " : "\033[31m[ERREUR]\033[0m ") . $res['message'] . "\n"; echo ($res['success'] ? "\033[32m[OK]\033[0m " : "\033[31m[ERREUR]\033[0m ") . $res['message'] . "\n";
break; break;
case 'init-ucp':
case 'sync-ucp':
case 'apply':
$settings = FirewallManager::getSettings();
$res = FirewallManager::saveSettings($settings);
@unlink('/tmp/nexus_fw_status_cache.json');
echo ($res['success'] ? "\033[32m[OK]\033[0m " : "\033[31m[ERREUR]\033[0m ") . ($res['message'] ?? '') . "\n";
break;
default: default:
echo "Usage: nexus-firewall [status|on|off|ban <ip>|unban <ip>]\n"; echo "Usage: nexus-firewall [status|on|off|init-ucp|ban <ip>|unban <ip>]\n";
exit(1); exit(1);
} }
+58 -9
View File
@@ -81,6 +81,9 @@ fi
echo -e "\n${C_YELLOW}-> Mise à jour vers la version v${REMOTE_VER}...${C_RESET}" echo -e "\n${C_YELLOW}-> Mise à jour vers la version v${REMOTE_VER}...${C_RESET}"
# Nettoyage préventif d'éventuels anciens dossiers temporaires HA dans /tmp
find /tmp -maxdepth 1 \( -name 'nexus_cluster_extract_*' -o -name 'nexus_cluster_tar_*' -o -name 'nexus_cluster_recv_*' -o -name 'nexus_cluster_sync_*' -o -name 'nexus_cluster_db_*' \) -mmin +10 -exec rm -rf {} + 2>/dev/null || true
# 1. Sauvegarde préventive automatique (Disaster Recovery complète) # 1. Sauvegarde préventive automatique (Disaster Recovery complète)
echo -e "${C_GRAY}[1/6] Création d'une sauvegarde préventive du système (Disaster Recovery)...${C_RESET}" echo -e "${C_GRAY}[1/6] Création d'une sauvegarde préventive du système (Disaster Recovery)...${C_RESET}"
php -r ' php -r '
@@ -151,26 +154,68 @@ done
# Mise à jour des privilèges sudoers (www-data) si accessible en écriture # Mise à jour des privilèges sudoers (www-data) si accessible en écriture
if [ -w /etc/sudoers.d ]; then if [ -w /etc/sudoers.d ]; then
cat << 'EOF_SUDO' > /etc/sudoers.d/nexus-pbx 2>/dev/null || true 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 EOF_SUDO
chmod 0440 /etc/sudoers.d/nexus-pbx 2>/dev/null || true chmod 0440 /etc/sudoers.d/nexus-pbx 2>/dev/null || true
fi fi
# 3. Vérification des dépendances Debian # 3. Vérification des dépendances Debian & extensions PHP
echo -e "${C_GRAY}[3/6] Vérification des composants et outils système...${C_RESET}" echo -e "${C_GRAY}[3/6] Vérification des composants et outils système...${C_RESET}"
which ffmpeg >/dev/null 2>&1 && which rsync >/dev/null 2>&1 && which sox >/dev/null 2>&1 && which sshpass >/dev/null 2>&1 && which keepalived >/dev/null 2>&1 && which arping >/dev/null 2>&1 || { if ! which sqlite3 >/dev/null 2>&1 || ! which ffmpeg >/dev/null 2>&1 || ! which rsync >/dev/null 2>&1 || ! which sox >/dev/null 2>&1 || ! which sshpass >/dev/null 2>&1 || ! which keepalived >/dev/null 2>&1 || ! which arping >/dev/null 2>&1; then
echo -e " -> Installation de paquets complémentaires..." echo -e " -> Installation de paquets système complémentaires..."
(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 (apt-get update -y -qq && apt-get install -y -qq --no-install-recommends sqlite3 acl ffmpeg rsync sox sshpass keepalived iputils-arping) 2>/dev/null || true
} fi
# 4. Exécution des migrations SQLite et régénération Dialplan PHP_MODS=$(php -m 2>/dev/null || true)
echo -e "${C_GRAY}[4/6] Exécution des migrations de base de données & régénération dialplan...${C_RESET}" if ! echo "$PHP_MODS" | grep -qi "^sqlite3$" || ! echo "$PHP_MODS" | grep -qi "^curl$" || ! echo "$PHP_MODS" | grep -qi "^xml$" || ! echo "$PHP_MODS" | grep -qi "^mbstring$" || ! echo "$PHP_MODS" | grep -qi "^zip$"; then
echo -e " -> Installation des extensions PHP manquantes..."
(apt-get update -y -qq && apt-get install -y -qq --no-install-recommends php-sqlite3 php-curl php-xml php-mbstring php-zip) 2>/dev/null || true
PHP_FPM_SVC=$(systemctl list-unit-files --type=service --no-legend 2>/dev/null | awk '/php.*-fpm\.service/ {print $1; exit}')
[ -n "$PHP_FPM_SVC" ] && systemctl restart "$PHP_FPM_SVC" >/dev/null 2>&1 || true
fi
# 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 ' php -r '
require_once "/var/www/nexus-pbx/src/Database.php"; 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/ConfigGenerator.php";
require_once "/var/www/nexus-pbx/src/FirewallManager.php";
$db = App\Database::getConnection(true); $db = App\Database::getConnection(true);
$gen = new App\ConfigGenerator($db); $gen = new App\ConfigGenerator($db);
$gen->writeAllConfigs(); $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 # 5. Fix permissions & ACLs
@@ -200,6 +245,9 @@ chmod 0664 /var/log/asterisk/cdr-csv/Master.csv 2>/dev/null || true
setfacl -R -m u:www-data:rwx,g:asterisk:rwx /etc/asterisk /var/lib/asterisk/sounds /var/lib/asterisk/moh /var/spool/asterisk /var/log/asterisk 2>/dev/null || true setfacl -R -m u:www-data:rwx,g:asterisk:rwx /etc/asterisk /var/lib/asterisk/sounds /var/lib/asterisk/moh /var/spool/asterisk /var/log/asterisk 2>/dev/null || true
setfacl -R -d -m u:www-data:rwx,g:asterisk:rwx /etc/asterisk /var/lib/asterisk/sounds /var/lib/asterisk/moh /var/spool/asterisk /var/log/asterisk 2>/dev/null || true setfacl -R -d -m u:www-data:rwx,g:asterisk:rwx /etc/asterisk /var/lib/asterisk/sounds /var/lib/asterisk/moh /var/spool/asterisk /var/log/asterisk 2>/dev/null || true
chmod 0664 /etc/asterisk/*.conf /etc/asterisk/*.custom.conf 2>/dev/null || true chmod 0664 /etc/asterisk/*.conf /etc/asterisk/*.custom.conf 2>/dev/null || true
chown root:asterisk /etc/fail2ban/jail.local /etc/nginx/sites-available/nexus-pbx 2>/dev/null || true
chmod 0664 /etc/fail2ban/jail.local /etc/nginx/sites-available/nexus-pbx 2>/dev/null || true
setfacl -m u:www-data:rw /etc/fail2ban/jail.local /etc/nginx/sites-available/nexus-pbx 2>/dev/null || true
# 6. Rechargement des services # 6. Rechargement des services
echo -e "${C_GRAY}[6/6] Rechargement à chaud d'Asterisk et des services web...${C_RESET}" echo -e "${C_GRAY}[6/6] Rechargement à chaud d'Asterisk et des services web...${C_RESET}"
@@ -211,8 +259,9 @@ asterisk -rx "dialplan reload" >/dev/null 2>&1 || true
if [ -t 1 ]; then 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 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 systemctl reload nginx 2>/dev/null || true
fail2ban-client reload >/dev/null 2>&1 || true
else 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 fi
NEW_VER="Inconnue" NEW_VER="Inconnue"
+259 -133
View File
@@ -495,78 +495,98 @@ class ClusterManager
throw new RuntimeException("Fichier de base de données introuvable : {$dbPath}"); throw new RuntimeException("Fichier de base de données introuvable : {$dbPath}");
} }
$this->cleanupStaleClusterTempFiles();
$uniq = getmypid() . '_' . bin2hex(random_bytes(4));
// Snapshot 100% atomique de la base NexusPBX via VACUUM INTO / .backup officiel SQLite // Snapshot 100% atomique de la base NexusPBX via VACUUM INTO / .backup officiel SQLite
$tmpDbDump = '/tmp/nexus_cluster_db_' . time() . '.sqlite'; $tmpDbDump = '/tmp/nexus_cluster_db_' . $uniq . '.sqlite';
@unlink($tmpDbDump); $tmpArchive = '/tmp/nexus_cluster_sync_' . $uniq . '.tar.gz';
$tmpAstdb = '/tmp/astdb_cluster_sync_' . $uniq . '.sqlite3';
$tmpDbDir = '/tmp/nexus_cluster_tar_' . $uniq;
$hasAstdb = false;
try { try {
$this->db->exec("VACUUM INTO " . $this->db->quote($tmpDbDump));
} catch (\Throwable $e) {
shell_exec("sqlite3 " . escapeshellarg($dbPath) . " \".backup " . escapeshellarg($tmpDbDump) . "\" 2>/dev/null || true");
}
if (!file_exists($tmpDbDump) || filesize($tmpDbDump) === 0) {
$this->db->exec('PRAGMA wal_checkpoint(FULL);');
@copy($dbPath, $tmpDbDump);
}
$dbIntegrity = trim(shell_exec("sqlite3 " . escapeshellarg($tmpDbDump) . " 'PRAGMA integrity_check;' 2>/dev/null") ?? '');
if ($dbIntegrity !== 'ok') {
@unlink($tmpDbDump); @unlink($tmpDbDump);
throw new RuntimeException("Échec de vérification d'intégrité de la base locale avant réplication : {$dbIntegrity}"); try {
} $this->db->exec("VACUUM INTO " . $this->db->quote($tmpDbDump));
} catch (\Throwable $e) {
shell_exec("sqlite3 " . escapeshellarg($dbPath) . " \".backup " . escapeshellarg($tmpDbDump) . "\" 2>/dev/null || true");
}
$tmpArchive = '/tmp/nexus_cluster_sync_' . time() . '.tar.gz'; if (!file_exists($tmpDbDump) || filesize($tmpDbDump) === 0) {
$tmpAstdb = '/tmp/astdb_cluster_sync.sqlite3'; $this->db->exec('PRAGMA wal_checkpoint(FULL);');
$hasAstdb = false; @copy($dbPath, $tmpDbDump);
}
// Sauvegarde intègre et atomique de la base de contacts Asterisk (astdb.sqlite3) $dbIntegrity = $this->checkSqliteIntegrity($tmpDbDump);
$astdbPath = '/var/lib/asterisk/astdb.sqlite3'; if ($dbIntegrity !== 'ok') {
if (file_exists($astdbPath) && is_readable($astdbPath)) { throw new RuntimeException("Échec de vérification d'intégrité de la base locale avant réplication : {$dbIntegrity}");
@unlink($tmpAstdb); }
shell_exec("sqlite3 " . escapeshellarg($astdbPath) . " \".backup " . escapeshellarg($tmpAstdb) . "\" 2>/dev/null || cp -f " . escapeshellarg($astdbPath) . " " . escapeshellarg($tmpAstdb) . " 2>/dev/null || true");
if (file_exists($tmpAstdb) && filesize($tmpAstdb) > 0) { // Sauvegarde intègre et atomique de la base de contacts Asterisk (astdb.sqlite3)
$integrity = trim(shell_exec("sqlite3 " . escapeshellarg($tmpAstdb) . " 'PRAGMA integrity_check;' 2>/dev/null") ?? ''); $astdbPath = '/var/lib/asterisk/astdb.sqlite3';
if ($integrity === 'ok') { if (file_exists($astdbPath) && is_readable($astdbPath)) {
// Purger les souscriptions persistantes PJSIP de l'archive (incompatibles entre instances Asterisk) @unlink($tmpAstdb);
shell_exec("sqlite3 " . escapeshellarg($tmpAstdb) . " \"DELETE FROM astdb WHERE key LIKE '/subscription_persistence/%';\" 2>/dev/null || true"); try {
$hasAstdb = true; if (class_exists(\SQLite3::class)) {
} else { $srcAst = new \SQLite3($astdbPath, SQLITE3_OPEN_READONLY);
@unlink($tmpAstdb); $dstAst = new \SQLite3($tmpAstdb);
$srcAst->backup($dstAst);
$srcAst->close();
$dstAst->close();
} else {
shell_exec("sqlite3 " . escapeshellarg($astdbPath) . " \".backup " . escapeshellarg($tmpAstdb) . "\" 2>/dev/null || cp -f " . escapeshellarg($astdbPath) . " " . escapeshellarg($tmpAstdb) . " 2>/dev/null || true");
}
} catch (\Throwable) {
@copy($astdbPath, $tmpAstdb);
}
if (file_exists($tmpAstdb) && filesize($tmpAstdb) > 0) {
$integrity = $this->checkSqliteIntegrity($tmpAstdb);
if ($integrity === 'ok') {
// Purger les souscriptions persistantes PJSIP de l'archive (incompatibles entre instances Asterisk)
try {
$astPdo = new PDO('sqlite:' . $tmpAstdb);
$astPdo->exec("DELETE FROM astdb WHERE key LIKE '/subscription_persistence/%'");
$astPdo = null;
} catch (\Throwable) {
shell_exec("sqlite3 " . escapeshellarg($tmpAstdb) . " \"DELETE FROM astdb WHERE key LIKE '/subscription_persistence/%';\" 2>/dev/null || true");
}
$hasAstdb = true;
}
} }
} }
}
// Préparation du dossier temporaire de tarage pour isoler database.sqlite // Préparation du dossier temporaire de tarage pour isoler database.sqlite
$tmpDbDir = '/tmp/nexus_cluster_tar_' . time(); @mkdir($tmpDbDir, 0755, true);
@mkdir($tmpDbDir, 0755, true); rename($tmpDbDump, $tmpDbDir . '/database.sqlite');
rename($tmpDbDump, $tmpDbDir . '/database.sqlite');
$cmd = "tar -czf " . escapeshellarg($tmpArchive) . " -C " . escapeshellarg($tmpDbDir) . " database.sqlite"; $cmd = "tar -czf " . escapeshellarg($tmpArchive) . " -C " . escapeshellarg($tmpDbDir) . " database.sqlite";
if (is_dir('/var/lib/asterisk/sounds/custom')) { if (is_dir('/var/lib/asterisk/sounds/custom')) {
$cmd .= " -C /var/lib/asterisk/sounds custom"; $cmd .= " -C /var/lib/asterisk/sounds custom";
} }
if (is_dir('/var/spool/asterisk/voicemail')) { if (is_dir('/var/spool/asterisk/voicemail')) {
$cmd .= " -C /var/spool/asterisk voicemail"; $cmd .= " -C /var/spool/asterisk voicemail";
} }
if ($hasAstdb) { if (is_dir('/var/www/nexus-pbx/public/avatars')) {
$cmd .= " -C /tmp astdb_cluster_sync.sqlite3"; $cmd .= " -C /var/www/nexus-pbx/public avatars";
} }
shell_exec($cmd); if ($hasAstdb) {
$cmd .= " -C /tmp " . escapeshellarg(basename($tmpAstdb));
}
exec($cmd . " 2>&1", $tarOut, $tarCode);
@unlink($tmpDbDir . '/database.sqlite'); if (!file_exists($tmpArchive) || filesize($tmpArchive) === 0 || $tarCode >= 2) {
@rmdir($tmpDbDir); throw new RuntimeException("Échec de génération de l'archive de réplication (tar code {$tarCode}).");
if ($hasAstdb) { }
$archiveData = file_get_contents($tmpArchive);
} finally {
@unlink($tmpDbDump);
@unlink($tmpAstdb); @unlink($tmpAstdb);
}
if (!file_exists($tmpArchive) || filesize($tmpArchive) === 0) {
@unlink($tmpArchive); @unlink($tmpArchive);
throw new RuntimeException("Échec de génération de l'archive de réplication."); shell_exec('rm -rf ' . escapeshellarg($tmpDbDir) . ' 2>/dev/null || true');
} }
$archiveData = file_get_contents($tmpArchive);
@unlink($tmpArchive);
// Collecte des adresses IP bannies dans Fail2ban (jails actives) // Collecte des adresses IP bannies dans Fail2ban (jails actives)
$bannedIps = $this->getActiveFail2banBans(); $bannedIps = $this->getActiveFail2banBans();
@@ -629,6 +649,9 @@ class ClusterManager
$resJson = json_decode((string)$response, true); $resJson = json_decode((string)$response, true);
$detail = !empty($resJson['message']) ? " - " . $resJson['message'] : ($err ? " : {$err}" : ""); $detail = !empty($resJson['message']) ? " - " . $resJson['message'] : ($err ? " : {$err}" : "");
$lastError = "Échec d'envoi vers {$targetUrl} (HTTP {$httpCode}){$detail}"; $lastError = "Échec d'envoi vers {$targetUrl} (HTTP {$httpCode}){$detail}";
if (in_array($httpCode, [400, 401, 403, 422], true) && !empty($resJson['message'])) {
break;
}
} }
} }
@@ -668,94 +691,113 @@ class ClusterManager
throw new RuntimeException("Paquet de réplication vide ou corrompu."); throw new RuntimeException("Paquet de réplication vide ou corrompu.");
} }
$archiveRaw = base64_decode($data['archive_b64']); $this->cleanupStaleClusterTempFiles();
$tmpArchive = '/tmp/nexus_cluster_recv_' . time() . '.tar.gz'; $uniqRecv = getmypid() . '_' . bin2hex(random_bytes(4));
file_put_contents($tmpArchive, $archiveRaw); $archiveRaw = base64_decode($data['archive_b64'], true);
if ($archiveRaw === false || $archiveRaw === '') {
// Extraction sécurisée dans un dossier temporaire pour validation préalable throw new RuntimeException("Archive de réplication invalide (décodage base64).");
$tmpExtractDir = '/tmp/nexus_cluster_extract_' . time();
@mkdir($tmpExtractDir, 0755, true);
shell_exec("tar -xzf " . escapeshellarg($tmpArchive) . " -C " . escapeshellarg($tmpExtractDir) . " 2>/dev/null || true");
$extractedDb = $tmpExtractDir . '/database.sqlite';
if (!file_exists($extractedDb) || filesize($extractedDb) === 0) {
@unlink($tmpArchive);
@rmdir($tmpExtractDir);
throw new RuntimeException("L'archive de réplication reçue ne contient pas de base de données valide.");
} }
// Contrôle d'intégrité SQLite strict du fichier reçu AVANT toute modification du système local $tmpArchive = '/tmp/nexus_cluster_recv_' . $uniqRecv . '.tar.gz';
$check = trim(shell_exec("sqlite3 " . escapeshellarg($extractedDb) . " 'PRAGMA integrity_check;' 2>/dev/null") ?? ''); $tmpExtractDir = '/tmp/nexus_cluster_extract_' . $uniqRecv;
if ($check !== 'ok') {
@unlink($extractedDb);
@rmdir($tmpExtractDir);
@unlink($tmpArchive);
throw new RuntimeException("La base de données reçue est corrompue ({$check}) : rejet strict de l'application.");
}
// Sauvegarde d'urgence de la base actuelle avant synchronisation
$dbPath = Database::getDatabasePath();
if (file_exists($dbPath)) {
@copy($dbPath, $dbPath . '.pre_cluster_sync.bak');
}
// Sauvegarder la configuration locale cluster et réseau pour ne pas l'écraser par celle du master
$localClusterSettings = $settings; $localClusterSettings = $settings;
$localSettingsRow = null; $localSettingsRow = null;
try { try {
$localSettingsRow = $this->db->query("SELECT local_net, local_networks_json, admin_https_port FROM settings WHERE id = 1")->fetch(PDO::FETCH_ASSOC); $written = @file_put_contents($tmpArchive, $archiveRaw);
} catch (\Throwable $e) {} if ($written === false || $written !== strlen($archiveRaw)) {
throw new RuntimeException("Espace insuffisant dans /tmp lors de l'écriture de l'archive HA.");
// Restauration officielle en ligne via l'API SQLite .restore
// Ne détruit aucun descripteur de fichier, ne touche pas aux journaux WAL/SHM,
// et n'invalide aucune lecture concurrente dans PHP-FPM.
if (file_exists($dbPath)) {
$restoreCmd = "sqlite3 " . escapeshellarg($dbPath) . " \".restore " . escapeshellarg($extractedDb) . "\" 2>&1";
$restoreOut = shell_exec($restoreCmd);
if (!empty($restoreOut)) {
error_log("[CLUSTER] Avertissement sqlite3 .restore : " . trim($restoreOut));
} }
} else {
@copy($extractedDb, $dbPath);
@chmod($dbPath, 0660);
@chown($dbPath, 'www-data');
@chgrp($dbPath, 'asterisk');
}
@unlink($extractedDb); // Extraction sécurisée dans un dossier temporaire pour validation préalable
@mkdir($tmpExtractDir, 0755, true);
exec("tar -xzf " . escapeshellarg($tmpArchive) . " -C " . escapeshellarg($tmpExtractDir) . " 2>&1", $extOut, $extCode);
// Contrôle d'intégrité immédiat après synchronisation $extractedDb = $tmpExtractDir . '/database.sqlite';
$postCheck = trim(shell_exec("sqlite3 " . escapeshellarg($dbPath) . " 'PRAGMA integrity_check;' 2>/dev/null") ?? ''); if (!file_exists($extractedDb) || filesize($extractedDb) === 0) {
if ($postCheck !== 'ok') { $detail = !empty($extOut) ? ' (' . implode(' | ', array_slice($extOut, 0, 2)) . ')' : '';
if (file_exists($dbPath . '.pre_cluster_sync.bak')) { throw new RuntimeException("L'archive de réplication reçue ne contient pas de base de données valide{$detail}.");
shell_exec("sqlite3 " . escapeshellarg($dbPath) . " \".restore " . escapeshellarg($dbPath . '.pre_cluster_sync.bak') . "\" 2>/dev/null || true");
} }
throw new RuntimeException("Échec de vérification d'intégrité après synchronisation ({$postCheck}). Restauration préventive effectuée.");
}
// Synchronisation miroir des sons personnalisés (avec suppression des sons effacés) // Contrôle d'intégrité SQLite strict du fichier reçu AVANT toute modification du système local
$extractedCustom = $tmpExtractDir . '/custom'; $check = $this->checkSqliteIntegrity($extractedDb);
if (is_dir($extractedCustom) && is_dir('/var/lib/asterisk/sounds')) { if ($check !== 'ok') {
shell_exec("rsync -a --delete " . escapeshellarg($extractedCustom . '/') . " /var/lib/asterisk/sounds/custom/ 2>/dev/null || true"); throw new RuntimeException("La base de données reçue est corrompue ({$check}) : rejet strict de l'application.");
shell_exec("chown -R asterisk:asterisk /var/lib/asterisk/sounds/custom 2>/dev/null || true"); }
}
// Synchronisation miroir de la messagerie vocale (avec suppression des messages effacés) // Sauvegarde d'urgence de la base actuelle avant synchronisation
$extractedVm = $tmpExtractDir . '/voicemail'; $dbPath = Database::getDatabasePath();
if (is_dir($extractedVm) && is_dir('/var/spool/asterisk/voicemail')) { if (file_exists($dbPath)) {
shell_exec("rsync -a --delete " . escapeshellarg($extractedVm . '/') . " /var/spool/asterisk/voicemail/ 2>/dev/null || true"); @copy($dbPath, $dbPath . '.pre_cluster_sync.bak');
shell_exec("chown -R asterisk:asterisk /var/spool/asterisk/voicemail 2>/dev/null || true"); }
}
// Restauration de l'astdb (contacts et IP actives des téléphones PJSIP) via le helper root // Sauvegarder la configuration locale cluster et réseau pour ne pas l'écraser par celle du master
$extractedAstdb = $tmpExtractDir . '/astdb_cluster_sync.sqlite3'; try {
if (file_exists($extractedAstdb)) { $localSettingsRow = $this->db->query("SELECT local_net, local_networks_json, admin_https_port FROM settings WHERE id = 1")->fetch(PDO::FETCH_ASSOC);
shell_exec('sudo -n ' . escapeshellcmd(self::CLUSTER_HELPER_PATH) . ' restore-astdb ' . escapeshellarg($extractedAstdb) . ' 2>&1'); } catch (\Throwable $e) {}
@unlink($extractedAstdb);
}
@rmdir($tmpExtractDir); // Restauration officielle en ligne via l'API SQLite (SQLite3::backup ou sqlite3 .restore)
@unlink($tmpArchive); if (file_exists($dbPath)) {
$this->restoreSqliteDatabase($extractedDb, $dbPath);
} else {
@copy($extractedDb, $dbPath);
@chmod($dbPath, 0660);
@chown($dbPath, 'www-data');
@chgrp($dbPath, 'asterisk');
}
@unlink($extractedDb);
// Contrôle d'intégrité immédiat après synchronisation
$postCheck = $this->checkSqliteIntegrity($dbPath);
if ($postCheck !== 'ok') {
if (file_exists($dbPath . '.pre_cluster_sync.bak')) {
$this->restoreSqliteDatabase($dbPath . '.pre_cluster_sync.bak', $dbPath);
}
throw new RuntimeException("Échec de vérification d'intégrité après synchronisation ({$postCheck}). Restauration préventive effectuée.");
}
// Synchronisation miroir des sons personnalisés (avec suppression des sons effacés)
$extractedCustom = $tmpExtractDir . '/custom';
if (is_dir($extractedCustom) && is_dir('/var/lib/asterisk/sounds')) {
shell_exec("rsync -a --delete " . escapeshellarg($extractedCustom . '/') . " /var/lib/asterisk/sounds/custom/ 2>/dev/null || true");
shell_exec("chown -R asterisk:asterisk /var/lib/asterisk/sounds/custom 2>/dev/null || true");
}
// Synchronisation miroir de la messagerie vocale (avec suppression des messages effacés)
$extractedVm = $tmpExtractDir . '/voicemail';
if (is_dir($extractedVm) && is_dir('/var/spool/asterisk/voicemail')) {
shell_exec("rsync -a --delete " . escapeshellarg($extractedVm . '/') . " /var/spool/asterisk/voicemail/ 2>/dev/null || true");
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_' . $uniqRecv . '.sqlite3';
if (!file_exists($extractedAstdb)) {
$candidates = glob($tmpExtractDir . '/astdb_cluster_sync*.sqlite3') ?: [];
if (!empty($candidates)) {
$extractedAstdb = $candidates[0];
}
}
if (file_exists($extractedAstdb)) {
shell_exec('sudo -n ' . escapeshellcmd(self::CLUSTER_HELPER_PATH) . ' restore-astdb ' . escapeshellarg($extractedAstdb) . ' 2>&1');
@unlink($extractedAstdb);
}
} finally {
shell_exec('rm -rf ' . escapeshellarg($tmpExtractDir) . ' 2>/dev/null || true');
@unlink($tmpArchive);
}
// Réouverture de la connexion DB fraîche // Réouverture de la connexion DB fraîche
$newDb = Database::getConnection(true); $newDb = Database::getConnection(true);
@@ -809,6 +851,17 @@ class ClusterManager
// Régénérer les configurations PJSIP et Asterisk // Régénérer les configurations PJSIP et Asterisk
ConfigGenerator::generateAndApply($newDb); ConfigGenerator::generateAndApply($newDb);
// Synchroniser la configuration Nginx/Fail2ban locale (dont ports et jail UCP répliqués)
try {
if (function_exists('posix_geteuid') && posix_geteuid() !== 0) {
@exec('sudo -n /var/www/nexus-pbx/scripts/nexus-firewall init-ucp >/dev/null 2>&1');
} else {
FirewallManager::saveSettings(FirewallManager::getSettings());
}
} catch (\Throwable $e) {
// Non-bloquant lors de la synchronisation HA
}
// Rechargement à chaud d'Asterisk pour appliquer immédiatement la configuration en RAM // Rechargement à chaud d'Asterisk pour appliquer immédiatement la configuration en RAM
try { try {
$ami = AmiClient::fromDatabase($newDb); $ami = AmiClient::fromDatabase($newDb);
@@ -1067,6 +1120,79 @@ class ClusterManager
]; ];
} }
private function cleanupStaleClusterTempFiles(): void
{
$cutoff = time() - 600;
$patterns = [
'/tmp/nexus_cluster_extract_*',
'/tmp/nexus_cluster_tar_*',
'/tmp/nexus_cluster_recv_*',
'/tmp/nexus_cluster_sync_*',
'/tmp/nexus_cluster_db_*',
'/tmp/astdb_cluster_sync*',
];
foreach ($patterns as $pattern) {
foreach (glob($pattern) ?: [] as $path) {
$mtime = @filemtime($path);
if ($mtime !== false && $mtime < $cutoff) {
if (is_dir($path)) {
shell_exec('rm -rf ' . escapeshellarg($path) . ' 2>/dev/null || true');
} else {
@unlink($path);
}
}
}
}
}
private function checkSqliteIntegrity(string $filePath): string
{
if (!file_exists($filePath) || filesize($filePath) === 0) {
return 'fichier manquant ou vide';
}
try {
$pdo = new PDO('sqlite:' . $filePath);
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$res = trim((string)$pdo->query('PRAGMA integrity_check;')->fetchColumn());
$pdo = null;
if ($res !== '') {
return $res;
}
} catch (\Throwable $e) {
$cliOut = trim((string)shell_exec('sqlite3 ' . escapeshellarg($filePath) . " 'PRAGMA integrity_check;' 2>&1"));
return $cliOut !== '' ? $cliOut : $e->getMessage();
}
$cliOut = trim((string)shell_exec('sqlite3 ' . escapeshellarg($filePath) . " 'PRAGMA integrity_check;' 2>&1"));
return $cliOut !== '' ? $cliOut : 'réponse integrity_check vide';
}
private function restoreSqliteDatabase(string $sourceFile, string $targetFile): void
{
if (class_exists(\SQLite3::class)) {
try {
$src = new \SQLite3($sourceFile, SQLITE3_OPEN_READONLY);
$dst = new \SQLite3($targetFile);
$dst->busyTimeout(5000);
$ok = $src->backup($dst);
$src->close();
$dst->close();
if ($ok) {
return;
}
} catch (\Throwable $e) {
error_log('[CLUSTER] Avertissement SQLite3::backup : ' . $e->getMessage());
}
}
$restoreCmd = 'sqlite3 ' . escapeshellarg($targetFile) . ' ".restore ' . escapeshellarg($sourceFile) . '" 2>&1';
$restoreOut = shell_exec($restoreCmd);
if (!empty($restoreOut)) {
error_log('[CLUSTER] Avertissement sqlite3 .restore : ' . trim((string)$restoreOut));
}
}
private function updateSyncStatus(string $status, string $message): void private function updateSyncStatus(string $status, string $message): void
{ {
$stmt = $this->db->prepare(' $stmt = $this->db->prepare('
+2 -2
View File
@@ -987,9 +987,9 @@ class ConfigGenerator
$lines[] = " same => n,Hangup()"; $lines[] = " same => n,Hangup()";
} }
$lines[] = " same => n(cf_{$num}),NoOp(Poste {$num} renvoi inconditionnel vers \${DB(CF/{$num})})"; $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(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[] = ""; $lines[] = "";
} }
+33 -1
View File
@@ -48,7 +48,9 @@ class Database
// Si la base existe déjà, éviter d'exécuter l'intégralité du DDL à chaque requête HTTP // 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. // ce qui sature SQLite de verrous exclusifs inutiles. On s'assure des colonnes requises.
try { try {
self::$instance->query("SELECT rtp_timeout_hold FROM settings LIMIT 1"); self::$instance->query("SELECT ucp_https_port, fail2ban_ucp_maxretry, groundwire_secret FROM settings LIMIT 1");
self::$instance->query("SELECT groundwire_enabled FROM extensions LIMIT 1");
self::$instance->query("SELECT id FROM ucp_users LIMIT 1");
} catch (\Throwable $e) { } catch (\Throwable $e) {
self::initializeSchema(); self::initializeSchema();
} }
@@ -116,6 +118,7 @@ class Database
encryption INTEGER NOT NULL DEFAULT 0 CHECK (encryption IN (0, 1, 2)), encryption INTEGER NOT NULL DEFAULT 0 CHECK (encryption IN (0, 1, 2)),
codecs VARCHAR(64) NOT NULL DEFAULT 'alaw,ulaw,opus', codecs VARCHAR(64) NOT NULL DEFAULT 'alaw,ulaw,opus',
max_contacts INTEGER NOT NULL DEFAULT 2, max_contacts INTEGER NOT NULL DEFAULT 2,
groundwire_enabled INTEGER NOT NULL DEFAULT 0 CHECK (groundwire_enabled IN (0, 1)),
enabled INTEGER NOT NULL DEFAULT 1 CHECK (enabled IN (0, 1)), enabled INTEGER NOT NULL DEFAULT 1 CHECK (enabled IN (0, 1)),
created_at DATETIME DEFAULT CURRENT_TIMESTAMP created_at DATETIME DEFAULT CURRENT_TIMESTAMP
); );
@@ -338,6 +341,21 @@ class Database
created_at DATETIME DEFAULT CURRENT_TIMESTAMP, created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_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; SQL;
$db->exec($schema); $db->exec($schema);
@@ -418,12 +436,25 @@ class Database
'fail2ban_web_maxretry' => "INTEGER NOT NULL DEFAULT 3", 'fail2ban_web_maxretry' => "INTEGER NOT NULL DEFAULT 3",
'fail2ban_web_bantime' => "INTEGER NOT NULL DEFAULT 3600", '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'", '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",
'groundwire_secret' => "VARCHAR(128) NOT NULL DEFAULT ''",
]; ];
foreach ($extraSettings as $colName => $colDef) { foreach ($extraSettings as $colName => $colDef) {
if (!in_array($colName, $existing, true)) { if (!in_array($colName, $existing, true)) {
$db->exec("ALTER TABLE settings ADD COLUMN {$colName} {$colDef}"); $db->exec("ALTER TABLE settings ADD COLUMN {$colName} {$colDef}");
} }
} }
// Clé HMAC persistante en base (partagée entre nœuds HA) pour les liens XML GroundWire
$gwSecret = (string)($db->query('SELECT groundwire_secret FROM settings WHERE id = 1')->fetchColumn() ?: '');
if ($gwSecret === '') {
$newSecret = bin2hex(random_bytes(32));
$db->prepare('UPDATE settings SET groundwire_secret = :s WHERE id = 1')->execute([':s' => $newSecret]);
}
} }
private static function migrateExtensionsTable(PDO $db): void private static function migrateExtensionsTable(PDO $db): void
@@ -466,6 +497,7 @@ class Database
'voicemail_attach' => "INTEGER NOT NULL DEFAULT 1", 'voicemail_attach' => "INTEGER NOT NULL DEFAULT 1",
'voicemail_delete' => "INTEGER NOT NULL DEFAULT 0", 'voicemail_delete' => "INTEGER NOT NULL DEFAULT 0",
'ring_time' => "INTEGER NOT NULL DEFAULT 20", 'ring_time' => "INTEGER NOT NULL DEFAULT 20",
'groundwire_enabled' => "INTEGER NOT NULL DEFAULT 0",
]; ];
foreach ($extraExtensionCols as $colName => $colDef) { foreach ($extraExtensionCols as $colName => $colDef) {
if (!in_array($colName, $existing, true)) { if (!in_array($colName, $existing, true)) {
+338 -26
View File
@@ -13,6 +13,9 @@ class FirewallManager
public const JAIL_LOCAL_PATH = '/etc/fail2ban/jail.local'; public const JAIL_LOCAL_PATH = '/etc/fail2ban/jail.local';
public const NGINX_CONF_PATH = '/etc/nginx/sites-available/nexus-pbx'; 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 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. * Récupère la configuration actuelle du pare-feu et de Fail2ban depuis SQLite.
@@ -23,7 +26,9 @@ class FirewallManager
$stmt = $db->query("SELECT $stmt = $db->query("SELECT
firewall_enabled, admin_https_port, certbot_strict_port80, firewall_enabled, admin_https_port, certbot_strict_port80,
fail2ban_sip_maxretry, fail2ban_sip_findtime, fail2ban_sip_bantime, 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"); FROM settings WHERE id = 1");
$row = $stmt->fetch(PDO::FETCH_ASSOC); $row = $stmt->fetch(PDO::FETCH_ASSOC);
@@ -38,6 +43,11 @@ class FirewallManager
'fail2ban_web_maxretry' => 3, 'fail2ban_web_maxretry' => 3,
'fail2ban_web_bantime' => 3600, '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', '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_maxretry' => (int)($row['fail2ban_web_maxretry'] ?? 3),
'fail2ban_web_bantime' => (int)($row['fail2ban_web_bantime'] ?? 3600), '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'), '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 // IP actuelle du client (avec résolution reverse proxy si proxy privé/local)
$currentIp = $_SERVER['HTTP_X_FORWARDED_FOR'] ?? $_SERVER['REMOTE_ADDR'] ?? '127.0.0.1'; $currentIp = self::resolveClientIp();
if (str_contains($currentIp, ',')) {
$currentIp = trim(explode(',', $currentIp)[0]);
}
$status = [ $status = [
'is_running' => $isRunning, 'is_running' => $isRunning,
@@ -151,7 +163,7 @@ class FirewallManager
} }
} else { } else {
// Débannir de toutes les prisons connues // 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"; $cmd = "timeout 2 sudo -n /usr/bin/fail2ban-client set " . escapeshellarg($j) . " unbanip " . escapeshellarg($ip) . " 2>&1";
exec($cmd, $out, $code); exec($cmd, $out, $code);
if ($code === 0) { if ($code === 0) {
@@ -200,6 +212,7 @@ class FirewallManager
{ {
$db = Database::getConnection(); $db = Database::getConnection();
$oldSettings = self::getSettings();
$strictPort80 = !empty($data['certbot_strict_port80']) ? 1 : 0; $strictPort80 = !empty($data['certbot_strict_port80']) ? 1 : 0;
$firewallEnabled = isset($data['firewall_enabled']) ? (!empty($data['firewall_enabled']) ? 1 : 0) : 1; $firewallEnabled = isset($data['firewall_enabled']) ? (!empty($data['firewall_enabled']) ? 1 : 0) : 1;
$adminPort = max(1, min(65535, (int)($data['admin_https_port'] ?? 8443))); $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))); $webMaxRetry = max(1, min(20, (int)($data['fail2ban_web_maxretry'] ?? 3)));
$webBanTime = max(60, min(31536000, (int)($data['fail2ban_web_bantime'] ?? 3600))); $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'] ?? '')); $whitelist = trim((string)($data['fail2ban_whitelist_ips'] ?? ''));
$cleanTokens = array_values(array_filter(preg_split('/\s+/', $whitelist))); $cleanTokens = array_values(array_filter(preg_split('/\s+/', $whitelist)));
@@ -233,22 +266,33 @@ class FirewallManager
fail2ban_web_maxretry = :wmr, fail2ban_web_maxretry = :wmr,
fail2ban_web_bantime = :wbt, fail2ban_web_bantime = :wbt,
fail2ban_whitelist_ips = :wl, 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 updated_at = CURRENT_TIMESTAMP
WHERE id = 1"); WHERE id = 1");
$stmt->execute([ $stmt->execute([
':fe' => $firewallEnabled, ':fe' => $firewallEnabled,
':ap' => $adminPort, ':ap' => $adminPort,
':sp80' => $strictPort80, ':sp80' => $strictPort80,
':smr' => $sipMaxRetry, ':smr' => $sipMaxRetry,
':sft' => $sipFindTime, ':sft' => $sipFindTime,
':sbt' => $sipBanTime, ':sbt' => $sipBanTime,
':wmr' => $webMaxRetry, ':wmr' => $webMaxRetry,
':wbt' => $webBanTime, ':wbt' => $webBanTime,
':wl' => $finalWhitelist, ':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( self::generateJailLocal(
$finalWhitelist, $finalWhitelist,
$sipMaxRetry, $sipMaxRetry,
@@ -256,7 +300,12 @@ class FirewallManager
$sipBanTime, $sipBanTime,
$webMaxRetry, $webMaxRetry,
$webBanTime, $webBanTime,
$adminPort $adminPort,
$ucpEnabled,
$ucpHttpsPort,
$ucpHttpPort,
$ucpMaxRetry,
$ucpBanTime
); );
// 3. Rechargement / Redémarrage propre de Fail2ban // 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'); exec('timeout 5 sudo -n /usr/bin/systemctl restart fail2ban 2>&1');
} }
// 4. Mise à jour de la configuration Nginx // 4. Mise à jour de la configuration Nginx & ouverture/fermeture dynamique des ports UCP
self::applyNginxConfig($adminPort, $strictPort80); 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'); @unlink('/tmp/nexus_fw_status_cache.json');
return [ return [
'success' => true, '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(), 'settings' => self::getSettings(),
]; ];
} }
@@ -287,9 +341,30 @@ class FirewallManager
int $sipBanTime, int $sipBanTime,
int $webMaxRetry, int $webMaxRetry,
int $webBanTime, int $webBanTime,
int $adminPort int $adminPort,
?int $ucpEnabled = null,
?int $ucpHttpsPort = null,
?int $ucpHttpPort = null,
?int $ucpMaxRetry = null,
?int $ucpBanTime = null
): void { ): 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}"; $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 $conf = <<<EOF
# Fichier généré automatiquement par NexusPBX # Fichier généré automatiquement par NexusPBX
@@ -331,6 +406,16 @@ bantime = {$webBanTime}
port = {$portsWeb} port = {$portsWeb}
protocol = tcp 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; EOF;
file_put_contents(self::JAIL_LOCAL_PATH, $conf); 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'; $phpSock = '/run/php/php8.4-fpm.sock';
if (!file_exists($phpSock)) { if (!file_exists($phpSock)) {
$found = glob('/run/php/php*-fpm.sock'); $found = glob('/run/php/php*-fpm.sock');
@@ -485,7 +582,19 @@ server {
} }
EOF; 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); file_put_contents(self::NGINX_CONF_PATH, $fullNginxConfig);
@chgrp(self::NGINX_CONF_PATH, 'asterisk'); @chgrp(self::NGINX_CONF_PATH, 'asterisk');
@chmod(self::NGINX_CONF_PATH, 0664); @chmod(self::NGINX_CONF_PATH, 0664);
@@ -500,6 +609,203 @@ EOF;
exec('sudo -n /usr/bin/systemctl reload nginx 2>&1'); 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";
$isNonRoot = function_exists('posix_geteuid') && posix_geteuid() !== 0;
if (!file_exists(self::UCP_FILTER_PATH) || @file_get_contents(self::UCP_FILTER_PATH) !== $filterContent) {
if (@file_put_contents(self::UCP_FILTER_PATH, $filterContent) !== false) {
@chmod(self::UCP_FILTER_PATH, 0644);
} elseif ($isNonRoot) {
@exec('sudo -n /var/www/nexus-pbx/scripts/nexus-firewall init-ucp >/dev/null 2>&1');
return;
}
}
if (!file_exists(self::UCP_SECURITY_LOG)) {
if (@touch(self::UCP_SECURITY_LOG) === false && $isNonRoot) {
@exec('sudo -n /var/www/nexus-pbx/scripts/nexus-firewall init-ucp >/dev/null 2>&1');
return;
}
}
@chown(self::UCP_SECURITY_LOG, 'www-data');
@chgrp(self::UCP_SECURITY_LOG, 'asterisk');
@chmod(self::UCP_SECURITY_LOG, 0664);
}
/**
* Auto-déploie les VHosts Nginx UCP et le filtre Fail2ban si absents (ex. après git pull).
*/
public static function ensureUcpDeployed(): void
{
$st = self::getSettings();
if (empty($st['ucp_enabled'])) {
return;
}
$httpsPort = (int)($st['ucp_https_port'] ?? 8444);
$httpPort = (int)($st['ucp_http_port'] ?? 8084);
if ($httpsPort <= 0 && $httpPort <= 0) {
return;
}
$nginxFile = '/etc/nginx/sites-available/nexus-pbx';
$nginxConf = is_readable($nginxFile) ? (string)@file_get_contents($nginxFile) : '';
$hasNginxUcp = str_contains($nginxConf, self::UCP_ROOT_DIR);
$hasFilter = file_exists(self::UCP_FILTER_PATH);
$hasLog = file_exists(self::UCP_SECURITY_LOG);
if ($hasNginxUcp && $hasFilter && $hasLog) {
return;
}
if (function_exists('posix_geteuid') && posix_geteuid() !== 0) {
exec('sudo -n /var/www/nexus-pbx/scripts/nexus-firewall init-ucp 2>&1', $out, $code);
if ($code === 0) {
return;
}
}
self::saveSettings($st);
}
/**
* 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 private static function getJailDetails(string $jail): array
{ {
exec('timeout 2 sudo -n /usr/bin/fail2ban-client status ' . escapeshellarg($jail) . ' 2>&1', $out); exec('timeout 2 sudo -n /usr/bin/fail2ban-client status ' . escapeshellarg($jail) . ' 2>&1', $out);
@@ -526,7 +832,12 @@ EOF;
$totalFailed = max($totalFailed, $logIncidents); $totalFailed = max($totalFailed, $logIncidents);
} elseif ($jail === 'nexus-web') { } elseif ($jail === 'nexus-web') {
$logIncidents = is_readable('/var/log/nexus_security.log') $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; : 0;
$totalFailed = max($totalFailed, $logIncidents); $totalFailed = max($totalFailed, $logIncidents);
} }
@@ -558,6 +869,7 @@ EOF;
return match ($jail) { return match ($jail) {
'asterisk' => 'Téléphonie SIP (PJSIP)', 'asterisk' => 'Téléphonie SIP (PJSIP)',
'nexus-web' => 'Interface Web Admin', 'nexus-web' => 'Interface Web Admin',
'nexus-ucp' => 'Portail Utilisateur (UCP)',
'sshd' => 'Accès Console SSH', 'sshd' => 'Accès Console SSH',
default => ucfirst($jail), default => ucfirst($jail),
}; };
+52 -27
View File
@@ -48,14 +48,22 @@ class PhonebookManager
CREATE INDEX IF NOT EXISTS idx_phonebook_group ON phonebook_contacts(group_name); CREATE INDEX IF NOT EXISTS idx_phonebook_group ON phonebook_contacts(group_name);
"); ");
// 1. Récupère la cartographie des groupes d'extensions // 1. Récupère la cartographie complète des groupes d'extensions (un poste peut appartenir à plusieurs groupes)
$egRows = $db->query("SELECT id, name, members FROM extension_groups")->fetchAll(); $egRows = $db->query("SELECT id, name, members FROM extension_groups ORDER BY name COLLATE NOCASE ASC")->fetchAll();
$extToGroup = []; $extToGroups = [];
foreach ($egRows as $eg) { foreach ($egRows as $eg) {
$grpName = trim((string)($eg['name'] ?? ''));
if ($grpName === '') {
continue;
}
$members = json_decode((string)$eg['members'], true) ?: []; $members = json_decode((string)$eg['members'], true) ?: [];
foreach ($members as $extNum) { foreach ($members as $extNum) {
if (!isset($extToGroup[(string)$extNum])) { $k = (string)$extNum;
$extToGroup[(string)$extNum] = trim((string)$eg['name']); if (!isset($extToGroups[$k])) {
$extToGroups[$k] = [];
}
if (!in_array($grpName, $extToGroups[$k], true)) {
$extToGroups[$k][] = $grpName;
} }
} }
} }
@@ -80,7 +88,7 @@ class PhonebookManager
$extNum = trim((string)$ext['extension']); $extNum = trim((string)$ext['extension']);
$extName = trim((string)$ext['name']); $extName = trim((string)$ext['name']);
$email = trim((string)($ext['voicemail_email'] ?? '')); $email = trim((string)($ext['voicemail_email'] ?? ''));
$group = $extToGroup[$extNum] ?? 'Général'; $group = !empty($extToGroups[$extNum]) ? implode(', ', $extToGroups[$extNum]) : 'Général';
$activeExtIds[] = $extId; $activeExtIds[] = $extId;
$findStmt->execute([':ext_id' => $extId, ':num' => $extNum]); $findStmt->execute([':ext_id' => $extId, ':num' => $extNum]);
@@ -122,33 +130,48 @@ class PhonebookManager
{ {
self::syncExtensions($db); self::syncExtensions($db);
$params = []; $stmt = $db->query("SELECT * FROM phonebook_contacts ORDER BY display_name COLLATE NOCASE ASC");
$sql = "SELECT * FROM phonebook_contacts"; $contacts = $stmt ? $stmt->fetchAll(PDO::FETCH_ASSOC) : [];
if ($group !== null && trim($group) !== '' && strtolower(trim($group)) !== 'all' && strtolower(trim($group)) !== 'tous') { $cleanGroup = ($group !== null) ? trim($group) : '';
$cleanGroup = trim($group); $filterByGroup = ($cleanGroup !== '' && !in_array(mb_strtolower($cleanGroup), ['all', 'tous'], true));
$sql .= " WHERE LOWER(group_name) = LOWER(:grp) OR LOWER(group_name) LIKE LOWER(:grp_like) OR LOWER(group_name) IN ('tous', 'all')"; $targetLower = mb_strtolower($cleanGroup);
$params[':grp'] = $cleanGroup;
$params[':grp_like'] = "%{$cleanGroup}%";
}
$sql .= " ORDER BY display_name COLLATE NOCASE ASC"; $result = [];
$stmt = $db->prepare($sql); foreach ($contacts as $c) {
$stmt->execute($params);
$contacts = $stmt->fetchAll(PDO::FETCH_ASSOC);
// Vérification de la présence réelle du fichier sur le disque
foreach ($contacts as &$c) {
if (!empty($c['photo_url'])) { if (!empty($c['photo_url'])) {
$filename = basename((string)$c['photo_url']); $filename = basename((string)$c['photo_url']);
if (!file_exists(self::AVATARS_DIR . '/' . $filename)) { if (!file_exists(self::AVATARS_DIR . '/' . $filename)) {
$c['photo_url'] = ''; $c['photo_url'] = '';
} }
} }
}
unset($c);
return $contacts; $rawGroups = preg_split('/\s*,\s*/', trim((string)($c['group_name'] ?? ''))) ?: [];
$groupsList = array_values(array_unique(array_filter($rawGroups, fn($g) => $g !== '')));
if (empty($groupsList)) {
$groupsList = ['Général'];
}
$c['groups'] = $groupsList;
$c['group_name'] = implode(', ', $groupsList);
if ($filterByGroup) {
$matched = false;
foreach ($groupsList as $g) {
$gLower = mb_strtolower($g);
if ($gLower === $targetLower || in_array($gLower, ['tous', 'all'], true)) {
$matched = true;
break;
}
}
if (!$matched) {
continue;
}
}
$result[] = $c;
}
return $result;
} }
/** /**
@@ -163,9 +186,11 @@ class PhonebookManager
if ($g !== '') $groups[$g] = true; if ($g !== '') $groups[$g] = true;
} }
$pbRows = $db->query("SELECT DISTINCT group_name FROM phonebook_contacts WHERE group_name != ''")->fetchAll(PDO::FETCH_COLUMN); $pbRows = $db->query("SELECT DISTINCT group_name FROM phonebook_contacts WHERE group_name != ''")->fetchAll(PDO::FETCH_COLUMN);
foreach ($pbRows as $g) { foreach ($pbRows as $raw) {
$g = trim((string)$g); foreach (preg_split('/\s*,\s*/', trim((string)$raw)) ?: [] as $g) {
if ($g !== '') $groups[$g] = true; $g = trim((string)$g);
if ($g !== '') $groups[$g] = true;
}
} }
// Ajoute Issy-Les-Moulineaux si pas encore présent // Ajoute Issy-Les-Moulineaux si pas encore présent
+197
View File
@@ -0,0 +1,197 @@
<?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, groundwire_enabled, 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,
]);
}
}
+1024
View File
File diff suppressed because it is too large. Load diff
+344
View File
@@ -0,0 +1,344 @@
<?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, $ucpUserId),
'forward_targets' => UcpManager::getForwardTargets($db),
'feature_codes' => UcpManager::getFeatureCodesForUser($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, $ucpUserId),
'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: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 795 KiB

+1207
View File
File diff suppressed because it is too large. Load diff
+89
View File
@@ -0,0 +1,89 @@
<?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;
try {
$db = Database::getConnection();
if (!UcpAuth::isUcpEnabled($db)) {
http_response_code(503);
header('Content-Type: text/plain; charset=utf-8');
echo "Le portail utilisateur UCP est actuellement désactivé.";
exit;
}
$extId = (int)($_GET['ext_id'] ?? $_GET['line_id'] ?? 0);
$uid = (int)($_GET['uid'] ?? 0);
$token = trim((string)($_GET['token'] ?? ''));
$authorized = false;
// 1. Authentification sans session par signature HMAC GET (utilisée par l'application GroundWire / Acrobits)
if ($token !== '' && $extId > 0 && $uid > 0) {
if (UcpManager::verifyGroundwireToken($db, $extId, $uid, $token)) {
$allowed = UcpAuth::getAllowedExtensions($db, $uid);
foreach ($allowed as $row) {
if ((int)$row['id'] === $extId && (int)($row['groundwire_enabled'] ?? 0) === 1) {
$authorized = true;
break;
}
}
}
}
// 2. Sinon : utilisateur UCP connecté en session et propriétaire de l'extension
if (!$authorized && $extId > 0) {
UcpAuth::startSession();
$sessionUser = UcpAuth::getCurrentUser($db);
if ($sessionUser !== null) {
$allowed = UcpAuth::getAllowedExtensions($db, (int)$sessionUser['id']);
foreach ($allowed as $row) {
if ((int)$row['id'] === $extId && (int)($row['groundwire_enabled'] ?? 0) === 1) {
$authorized = true;
break;
}
}
}
}
if (!$authorized) {
http_response_code(403);
header('Content-Type: text/plain; charset=utf-8');
echo "Accès refusé ou lien de provisionnement invalide.";
exit;
}
$bundle = UcpManager::buildGroundwireXml($db, $extId);
$xmlContent = $bundle['xml'];
while (ob_get_level()) {
ob_end_clean();
}
header('Content-Type: application/xml; charset=utf-8');
header('Content-Disposition: attachment; filename="' . $bundle['filename'] . '"');
header('Content-Length: ' . strlen($xmlContent));
header('Cache-Control: no-store, no-cache, must-revalidate');
echo $xmlContent;
} catch (\InvalidArgumentException $e) {
http_response_code(404);
header('Content-Type: text/plain; charset=utf-8');
echo $e->getMessage();
} catch (\Throwable $e) {
http_response_code(500);
header('Content-Type: text/plain; charset=utf-8');
echo "Erreur interne lors de la génération du profil XML.";
}
+576
View File
@@ -0,0 +1,576 @@
<?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, viewport-fit=cover">
<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>
html, body {
max-width: 100%;
overflow-x: hidden;
-webkit-text-size-adjust: 100%;
}
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); }
@media screen and (max-width: 639px) {
input, select, textarea {
font-size: 16px !important;
}
}
.ucp-nav-scroll {
scrollbar-width: none;
-ms-overflow-style: none;
}
.ucp-nav-scroll::-webkit-scrollbar {
display: none;
}
::-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; }
@page {
size: A4 portrait;
margin: 11mm 12mm;
}
@media print {
html, body {
background: #ffffff !important;
color: #0f172a !important;
-webkit-print-color-adjust: exact !important;
print-color-adjust: exact !important;
}
header, #ucp-toast-container, #ucp-profile-modal, .no-print {
display: none !important;
}
main {
max-width: 100% !important;
padding: 0 !important;
margin: 0 !important;
}
.ucp-panel {
display: none !important;
}
#ucp-panel-guide {
display: block !important;
margin: 0 !important;
padding: 0 !important;
}
#ucp-guide-printable {
border: none !important;
box-shadow: none !important;
padding: 0 !important;
margin: 0 !important;
}
.ucp-print-avoid-break {
break-inside: avoid !important;
page-break-inside: avoid !important;
}
.ucp-print-3col {
display: grid !important;
grid-template-columns: repeat(3, minmax(0, 1fr)) !important;
gap: 0.75rem !important;
}
.ucp-print-row {
flex-direction: row !important;
align-items: center !important;
}
}
</style>
</head>
<body class="min-h-full font-sans antialiased text-slate-800">
<!-- Toast Container -->
<div id="ucp-toast-container" class="fixed top-4 inset-x-3 sm:inset-x-auto sm:right-4 z-50 flex flex-col gap-2 sm:max-w-sm sm:w-full pointer-events-none"></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">
<svg class="w-6 h-6" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M16.5 10.5V6.75a4.5 4.5 0 10-9 0v3.75m-.75 11.25h10.5a2.25 2.25 0 002.25-2.25v-6.75a2.25 2.25 0 00-2.25-2.25H6.75a2.25 2.25 0 00-2.25 2.25v6.75a2.25 2.25 0 002.25 2.25z"/></svg>
</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 (WALLPAPER CLAIR + CARTE GLASSMORPHISM) -->
<div class="min-h-screen flex flex-col items-center justify-center p-4 relative overflow-hidden bg-slate-100">
<div class="absolute inset-0 bg-cover bg-center bg-no-repeat" style="background-image: url('/assets/login-bg.jpg?v=<?= file_exists(__DIR__ . '/assets/login-bg.jpg') ? filemtime(__DIR__ . '/assets/login-bg.jpg') : 1 ?>');"></div>
<div class="absolute inset-0 bg-white/10"></div>
<div class="relative z-10 w-full max-w-md bg-white/85 backdrop-blur-xl rounded-3xl p-8 shadow-xl shadow-indigo-900/10 border border-white/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="Votre identifiant"
class="w-full px-3.5 py-2.5 rounded-xl bg-slate-50 border border-slate-300 text-base sm: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-base sm: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 overflow-x-hidden">
<!-- 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"><span class="hidden sm:inline">Espace personnel de </span><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" aria-label="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()" title="Déconnexion" aria-label="Déconnexion"
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 class="hidden sm:inline">Déconnexion</span>
</button>
</div>
</div>
<!-- Navigation Onglets avec dégradé latéral discret sur mobile -->
<div class="relative max-w-6xl mx-auto border-t border-slate-100">
<button type="button" id="ucp-nav-hint-left" onclick="scrollUcpNav(-1)" aria-label="Onglets précédents"
class="hidden absolute left-0 top-0 bottom-0 z-10 w-9 items-center justify-start pl-1 bg-gradient-to-r from-white via-white/95 to-transparent text-slate-400 hover:text-indigo-600 transition">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.2" d="M15 19l-7-7 7-7"/></svg>
</button>
<div id="ucp-nav-scroller" class="ucp-nav-scroll px-4 sm:px-6 overflow-x-auto scroll-smooth">
<nav class="flex items-center gap-1 py-2 whitespace-nowrap pr-6 sm:pr-0" 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">
<svg class="w-4 h-4 shrink-0" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M10.5 1.5H8.25A2.25 2.25 0 006 3.75v16.5a2.25 2.25 0 002.25 2.25h7.5A2.25 2.25 0 0018 20.25V3.75a2.25 2.25 0 00-2.25-2.25H13.5m-3 0V3h3V1.5m-3 0h3m-3 18.75h3"/></svg>
<span>Mes Postes &amp; 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">
<svg class="w-4 h-4 shrink-0" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M21.75 9v.906a2.25 2.25 0 01-1.183 1.981l-6.478 3.488M2.25 9v.906a2.25 2.25 0 001.183 1.981l6.478 3.488m8.839 2.51l-4.66-2.51m0 0l-1.023-.55a2.25 2.25 0 00-2.134 0l-1.022.55m0 0l-4.661 2.51m16.5 1.615a2.25 2.25 0 01-2.25 2.25h-15a2.25 2.25 0 01-2.25-2.25V8.844a2.25 2.25 0 011.183-1.98l7.5-4.04a2.25 2.25 0 012.134 0l7.5 4.04a2.25 2.25 0 011.183 1.98V19.5z"/></svg>
<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">
<svg class="w-4 h-4 shrink-0" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M2.25 6.75c0 8.284 6.716 15 15 15h2.25a2.25 2.25 0 002.25-2.25v-1.372c0-.516-.351-.966-.852-1.091l-4.423-1.106c-.44-.11-.902.055-1.173.417l-.97 1.293c-.282.376-.769.542-1.21.38a12.035 12.035 0 01-7.143-7.143c-.162-.441.004-.928.38-1.21l1.293-.97c.363-.271.527-.734.417-1.173L6.963 3.102a1.125 1.125 0 00-1.091-.852H4.5A2.25 2.25 0 002.25 4.5v2.25z"/></svg>
<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">
<svg class="w-4 h-4 shrink-0" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M12 6.042A8.967 8.967 0 006 3.75c-1.052 0-2.062.18-3 .512v14.25A8.987 8.987 0 016 18c2.305 0 4.408.867 6 2.292m0-14.25a8.966 8.966 0 016-2.292c1.052 0 2.062.18 3 .512v14.25A8.987 8.987 0 0018 18a8.967 8.967 0 00-6 2.292m0-14.25v14.25"/></svg>
<span>Annuaire &amp; Profil</span>
</button>
<button type="button" data-ucp-tab="guide" onclick="switchUcpTab('guide')"
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">
<svg class="w-4 h-4 shrink-0" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M9 12h3.75M9 15h3.75M9 18h3.75m3 .75H18a2.25 2.25 0 002.25-2.25V6.108c0-1.135-.845-2.098-1.976-2.192a48.424 48.424 0 00-1.123-.08m-5.801 0c-.065.21-.1.433-.1.664 0 .414.336.75.75.75h4.5a.75.75 0 00.75-.75 2.25 2.25 0 00-.1-.664m-5.8 0A2.251 2.251 0 0113.5 2.25H15c1.012 0 1.867.668 2.15 1.586m-5.8 0c-.376.023-.75.05-1.124.08C9.095 4.01 8.25 4.973 8.25 6.108V8.25m0 0H4.875c-.621 0-1.125.504-1.125 1.125v11.25c0 .621.504 1.125 1.125 1.125h9.75c.621 0 1.125-.504 1.125-1.125V9.375c0-.621-.504-1.125-1.125-1.125H8.25z"/></svg>
<span>Notice &amp; Codes</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">
<svg class="w-4 h-4 shrink-0" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M15.75 5.25a3 3 0 013 3m3 0a6 6 0 01-7.029 5.912c-.563-.097-1.159.026-1.563.43L10.5 17.25H8.25v2.25H6v2.25H2.25v-2.818c0-.597.237-1.17.659-1.591l6.499-6.499c.404-.404.527-1 .43-1.563A6 6 0 1121.75 8.25z"/></svg>
<span>Mon Compte</span>
</button>
</nav>
</div>
<button type="button" id="ucp-nav-hint-right" onclick="scrollUcpNav(1)" aria-label="Onglets suivants"
class="hidden absolute right-0 top-0 bottom-0 z-10 w-10 items-center justify-end pr-1.5 bg-gradient-to-l from-white via-white/95 to-transparent text-slate-400 hover:text-indigo-600 transition">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.2" d="M9 5l7 7-7 7"/></svg>
</button>
</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 &amp; 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>
<!-- Vue mobile style journal d'appels iPhone (< 768px) -->
<div id="ucp-cdr-mobile-list" class="md:hidden space-y-2.5">
<div class="ucp-card rounded-2xl p-6 text-center text-xs text-slate-400">Chargement…</div>
</div>
<!-- Vue tableau complète sur ordinateur (>= 768px) -->
<div class="hidden md:block 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 &amp; 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-group-tabs" class="flex flex-wrap items-center gap-2"></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 : NOTICE TÉLÉPHONIE, CODES DE FONCTION & EXPORT PDF -->
<section id="ucp-panel-guide" class="ucp-panel hidden space-y-5">
<!-- Barre d'actions écran uniquement -->
<div class="no-print flex flex-col sm:flex-row sm:items-center justify-between gap-3">
<div>
<h2 class="text-lg font-bold text-slate-900">Notice d'Utilisation &amp; Codes de Fonction</h2>
<p class="text-xs text-slate-500">Mémo des raccourcis téléphoniques actifs et fiche pratique imprimable en PDF.</p>
</div>
<div class="flex flex-wrap items-center gap-2.5">
<input type="text" id="ucp-guide-search" oninput="renderFeatureGuide()" placeholder="Filtrer un code ou service (*97, renvoi…)"
class="w-full sm:w-64 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">
<button type="button" onclick="printFeatureGuide()"
class="inline-flex items-center gap-2 px-4 py-2 rounded-xl bg-indigo-600 hover:bg-indigo-700 text-white text-xs font-semibold shadow-sm shadow-indigo-500/20 transition">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 17h2a2 2 0 002-2v-4a2 2 0 00-2-2H5a2 2 0 00-2 2v4a2 2 0 002 2h2m2 4h6a2 2 0 002-2v-4a2 2 0 00-2-2H9a2 2 0 00-2 2v4a2 2 0 002 2zm8-12V5a2 2 0 00-2-2H9a2 2 0 00-2 2v4h10z"/></svg>
<span>Imprimer / Exporter en PDF</span>
</button>
</div>
</div>
<!-- Document A4 Propre & Imprimable -->
<div id="ucp-guide-printable" class="ucp-card rounded-3xl p-6 sm:p-8 space-y-6">
<!-- En-tête Corporate du document -->
<div class="ucp-print-avoid-break ucp-print-row rounded-2xl bg-gradient-to-r from-slate-900 via-indigo-950 to-slate-900 text-white p-5 sm:p-6 flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div class="flex items-center gap-4 min-w-0">
<div class="h-14 w-14 sm:h-16 sm:w-16 rounded-2xl bg-white p-2 flex items-center justify-center shrink-0 shadow-md">
<?php if ($companyLogo !== ''): ?>
<img src="<?= htmlspecialchars($companyLogo) ?>" alt="Logo" class="max-h-full max-w-full object-contain">
<?php else: ?>
<img src="/assets/logo.png?v=1" alt="NexusPBX" class="max-h-full max-w-full object-contain">
<?php endif; ?>
</div>
<div class="min-w-0">
<div class="text-[10px] font-bold uppercase tracking-widest text-indigo-300">Fiche Mémo Utilisateur • Téléphonie IP</div>
<h3 class="text-lg sm:text-xl font-extrabold tracking-tight truncate"><?= htmlspecialchars($companyName ?: 'NexusPBX') ?></h3>
<p class="text-xs text-slate-300 mt-0.5">Guide des codes de fonction, messagerie vocale et renvois d'appel</p>
</div>
</div>
<div class="sm:text-right bg-white/10 border border-white/15 rounded-xl px-4 py-2.5 text-xs space-y-1 shrink-0">
<div class="text-slate-300">Utilisateur : <strong class="text-white"><?= htmlspecialchars((string)($currentUser['display_name'] ?: $currentUser['username'])) ?></strong></div>
<div class="text-slate-300">Poste(s) : <strong id="ucp-guide-user-exts" class="font-mono text-indigo-200">—</strong></div>
<div class="text-[10px] text-slate-400">Édité le <span id="ucp-guide-print-date"><?= date('d/m/Y') ?></span> • v<?= htmlspecialchars($appVersion) ?></div>
</div>
</div>
<!-- Encarts Notice Pratique Rapide -->
<div id="ucp-guide-quickcards" class="ucp-print-3col grid grid-cols-1 md:grid-cols-3 gap-4"></div>
<!-- Liste complète structurée par catégorie -->
<div id="ucp-guide-categories" class="space-y-4">
<div class="text-center text-sm text-slate-400 py-8">Chargement des codes de fonction…</div>
</div>
<!-- Pied de document -->
<div class="ucp-print-avoid-break pt-4 border-t border-slate-200 flex flex-col sm:flex-row sm:items-center justify-between gap-2 text-[11px] text-slate-500">
<div class="flex items-center gap-1.5">
<svg class="w-4 h-4 text-amber-500 shrink-0" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M12 18v-5.25m0 0a6.01 6.01 0 001.5-.189m-1.5.189a6.01 6.01 0 01-1.5-.189m3.75 7.478a12.06 12.06 0 01-4.5 0m3.75 2.383a14.406 14.406 0 01-3 0M14.25 18v-.192c0-.983.658-1.823 1.508-2.316a7.5 7.5 0 10-7.517 0c.85.493 1.509 1.333 1.509 2.316V18"/></svg>
<span><strong>Astuce :</strong> composez ces codes directement sur votre combiné IP, ou pilotez DND / renvois / messagerie depuis le portail UCP.</span>
</div>
<div class="font-semibold text-slate-600"><?= htmlspecialchars($companyName ?: 'NexusPBX') ?> — Document interne</div>
</div>
</div>
</section>
<!-- ONGLET 6 : 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">
<svg class="w-4 h-4 text-slate-500 shrink-0" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M6.827 6.175A2.31 2.31 0 015.186 7.23c-.38.054-.757.112-1.134.175C2.999 7.58 2.25 8.507 2.25 9.574V18a2.25 2.25 0 002.25 2.25h15A2.25 2.25 0 0021.75 18V9.574c0-1.067-.75-1.994-1.802-2.169a47.865 47.865 0 00-1.134-.175 2.31 2.31 0 01-1.64-1.055l-.822-1.316a2.192 2.192 0 00-1.736-1.039 48.774 48.774 0 00-5.232 0 2.192 2.192 0 00-1.736 1.039l-.821 1.316z"/><path stroke-linecap="round" stroke-linejoin="round" d="M16.5 12.75a4.5 4.5 0 11-9 0 4.5 4.5 0 019 0z"/></svg>
<span>Choisir une photo</span>
<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>
+28 -13
View File
@@ -1,18 +1,33 @@
{ {
"version": "2.0.0", "version": "2.6.7",
"codename": "Pulsar", "codename": "Pulsar UCP",
"release_date": "2026-11-11", "release_date": "2026-11-12",
"min_php_version": "8.2", "min_php_version": "8.2",
"changelog": [ "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", "version": "2.6.7",
"HA Télécom : liaison stricte des transports PJSIP sur la VIP et annonce exclusive de la VIP en signalisation/média", "date": "2026-11-12",
"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", "changes": [
"HA Télécom : réveil BLF progressif automatique (check-sync) dès réenregistrement des postes sur le nouveau Master", "UI Admin & UCP : Remplacement de l'ensemble des icônes emoji par des icônes vectorielles SVG uniformes (style Heroicons), sans modifier le menu latéral Admin",
"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)", "Correction d'un problème de syntaxe JS sur le badge d'avertissement TLS dans app.js"
"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", "UI v2.6.6 : menu déroulant personnalisé sombre avec icônes SVG Heroicons pour toutes les destinations d'appel (sans emojis natifs)",
"Paramètres SIP : fiabilisation du rtp_timeout_hold et expiration AOR plafonnée (120s-300s) pour maintenir les tunnels NAT actifs" "UI v2.6.5 : refonte des intitulés de destinations audio (Musique d'attente & Annonce vocale en boucle continue), nouveau wallpaper clair UCP et placeholder générique",
"Correctif v2.6.4 : prise en charge multi-groupes dans l'annuaire UCP & Admin (un poste appartenant à plusieurs groupes apparaît bien dans chacun des onglets correspondants)",
"UI v2.6.3 : onglets dynamiques par groupe (Tous, Général, Paris, New-York...) avec compteurs dans l'Annuaire UCP",
"Correctif v2.6.2 : sélection stricte de <host> GroundWire (NAT si renseigné, sinon VIP HA si cluster actif, sinon IP locale + port d'écoute local)",
"UI v2.6.1 : suppression de l'icône smartphone sur les éléments GroundWire et refonte des boutons d'action du tableau Postes (Redémarrer / Modifier / Supprimer)",
"Nouveauté v2.6.0 : provisionnement dynamique XML Acrobits GroundWire par poste (option Admin « GroundWire XML », deep link mobile provlinkbs:, téléchargement XML signé HMAC sans session, liens App Store / Google Play dans l'UCP)",
"UI v2.5.7 : journal d'appels UCP en cartes style iOS sur mobile (<768px) tout en conservant le tableau 6 colonnes sur ordinateur",
"Correctif v2.5.6 : ergonomie mobile iPhone (en-tête compact + dégradé latéral discret sur la barre d'onglets UCP sans scrollbar visible)",
"Correctif v2.5.5 : suppression du zoom automatique iOS/iPhone sur les champs (<16px) et blocage du débordement horizontal mobile dans l'UCP",
"Correctif v2.5.4 : correction du journal personnel UCP « Mes Appels » (correspondance caller_number/callee_number/canaux PJSIP et filtres Répondus/Manqués)",
"Nouveauté v2.5.3 : onglet UCP « Notice & Codes » avec mémo pas-à-pas, codes de fonction dynamiques et export PDF / impression A4 corporate (logo, encarts, poste utilisateur)",
"Correctif v2.5.2 : purge systématique try/finally + auto-nettoyage des dossiers temporaires HA /tmp (élimine la saturation tmpfs 100% qui bloquait la réplication)",
"Correctif v2.5.1 : auto-déploiement automatique Nginx/Fail2ban UCP même après un git pull manuel",
"Correctif v2.5.1 : vérification d'intégrité & restauration SQLite HA via PDO / SQLite3::backup sans dépendance CLI sqlite3 et anti-collision /tmp",
"Correctif v2.5.1 : rafraîchissement instantané de la liste des utilisateurs UCP à la création/suppression sans rechargement manuel de la page",
"NexusPBX 2.5.0 « Pulsar UCP » (Issue #4) : Portail Utilisateur Isolé (User Control Panel)"
] ]
} }