896 lines
35 KiB
HTML
896 lines
35 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="fr">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta http-equiv="Content-Security-Policy" content="default-src 'self' https: 'unsafe-inline' 'unsafe-eval'; img-src 'self' https: data:;">
|
|
<title>OFFICE APP — Hub unifié Microsoft 365 · WEVAL</title>
|
|
<meta name="description" content="OFFICE APP — Hub unifié Microsoft Graph API : tenants, backdoors, scripts, intents chat. Zéro manuel, automation via client_credentials.">
|
|
<link rel="icon" href="data:,">
|
|
<style>
|
|
:root {
|
|
--bg: #0a0e1a;
|
|
--bg-2: #111829;
|
|
--bg-3: #1a2336;
|
|
--border: rgba(99, 102, 241, 0.22);
|
|
--border-hot: rgba(239, 68, 68, 0.45);
|
|
--ink: #e2e8f0;
|
|
--ink-dim: #94a3b8;
|
|
--ink-faint: #64748b;
|
|
--accent: #a5b4fc;
|
|
--accent-2: #5eead4;
|
|
--accent-ok: #34d399;
|
|
--accent-warn: #fbbf24;
|
|
--accent-fail: #f87171;
|
|
--font-display: 'JetBrains Mono', 'SF Mono', ui-monospace, monospace;
|
|
--font-body: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
|
|
}
|
|
* { box-sizing: border-box; }
|
|
html, body { margin: 0; padding: 0; }
|
|
body {
|
|
background: var(--bg);
|
|
background-image:
|
|
radial-gradient(ellipse at top, rgba(99,102,241,0.08) 0%, transparent 50%),
|
|
radial-gradient(ellipse at bottom right, rgba(94,234,212,0.05) 0%, transparent 50%);
|
|
background-attachment: fixed;
|
|
color: var(--ink);
|
|
font-family: var(--font-body);
|
|
line-height: 1.5;
|
|
min-height: 100vh;
|
|
}
|
|
.wrap { max-width: 1400px; margin: 0 auto; padding: 24px; }
|
|
|
|
/* Header */
|
|
header.ofh {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
padding-bottom: 18px;
|
|
border-bottom: 1px solid var(--border);
|
|
margin-bottom: 28px;
|
|
}
|
|
header.ofh h1 {
|
|
font-family: var(--font-display);
|
|
font-size: 22px;
|
|
font-weight: 700;
|
|
letter-spacing: -0.02em;
|
|
margin: 0;
|
|
color: var(--ink);
|
|
}
|
|
header.ofh h1 .tag {
|
|
display: inline-block;
|
|
padding: 2px 8px;
|
|
margin-left: 10px;
|
|
font-size: 11px;
|
|
background: linear-gradient(90deg, #6366f1, #5eead4);
|
|
background-clip: text;
|
|
-webkit-background-clip: text;
|
|
color: transparent;
|
|
border: 1px solid var(--accent);
|
|
border-radius: 4px;
|
|
}
|
|
header.ofh .sub { color: var(--ink-dim); font-size: 13px; margin-top: 4px; }
|
|
header.ofh nav { display: flex; gap: 14px; font-size: 13px; }
|
|
header.ofh nav a {
|
|
color: var(--ink-dim);
|
|
text-decoration: none;
|
|
padding: 6px 12px;
|
|
border: 1px solid var(--border);
|
|
border-radius: 6px;
|
|
transition: all .15s;
|
|
}
|
|
header.ofh nav a:hover { color: var(--accent); border-color: var(--accent); }
|
|
|
|
/* Stats grid */
|
|
.stats {
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
|
gap: 14px;
|
|
margin-bottom: 28px;
|
|
}
|
|
.stat-card {
|
|
background: var(--bg-2);
|
|
border: 1px solid var(--border);
|
|
border-radius: 10px;
|
|
padding: 18px;
|
|
position: relative;
|
|
overflow: hidden;
|
|
}
|
|
.stat-card::before {
|
|
content: '';
|
|
position: absolute;
|
|
top: 0; left: 0;
|
|
width: 3px; height: 100%;
|
|
background: var(--accent);
|
|
}
|
|
.stat-card.ok::before { background: var(--accent-ok); }
|
|
.stat-card.warn::before { background: var(--accent-warn); }
|
|
.stat-card.fail::before { background: var(--accent-fail); }
|
|
.stat-card .lbl {
|
|
font-size: 11px;
|
|
color: var(--ink-dim);
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.08em;
|
|
margin-bottom: 8px;
|
|
font-family: var(--font-display);
|
|
}
|
|
.stat-card .val {
|
|
font-size: 28px;
|
|
font-weight: 700;
|
|
font-family: var(--font-display);
|
|
color: var(--ink);
|
|
line-height: 1;
|
|
}
|
|
.stat-card .sub {
|
|
font-size: 11px;
|
|
color: var(--ink-faint);
|
|
margin-top: 6px;
|
|
}
|
|
|
|
/* Tabs */
|
|
.tabs {
|
|
display: flex;
|
|
gap: 2px;
|
|
border-bottom: 1px solid var(--border);
|
|
margin-bottom: 20px;
|
|
overflow-x: auto;
|
|
}
|
|
.tab {
|
|
padding: 10px 18px;
|
|
font-size: 13px;
|
|
font-family: var(--font-display);
|
|
background: transparent;
|
|
border: none;
|
|
border-bottom: 2px solid transparent;
|
|
color: var(--ink-dim);
|
|
cursor: pointer;
|
|
transition: all .15s;
|
|
white-space: nowrap;
|
|
}
|
|
.tab:hover { color: var(--ink); }
|
|
.tab.active { color: var(--accent); border-bottom-color: var(--accent); }
|
|
|
|
.panel { display: none; }
|
|
.panel.active { display: block; }
|
|
|
|
/* Tables */
|
|
.tbl-wrap {
|
|
background: var(--bg-2);
|
|
border: 1px solid var(--border);
|
|
border-radius: 10px;
|
|
overflow: hidden;
|
|
}
|
|
.tbl-head {
|
|
padding: 14px 18px;
|
|
background: var(--bg-3);
|
|
border-bottom: 1px solid var(--border);
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
}
|
|
.tbl-head h3 {
|
|
margin: 0;
|
|
font-family: var(--font-display);
|
|
font-size: 13px;
|
|
color: var(--accent);
|
|
letter-spacing: 0.02em;
|
|
}
|
|
.tbl-head .info { font-size: 11px; color: var(--ink-faint); }
|
|
table { width: 100%; border-collapse: collapse; font-size: 13px; }
|
|
table th {
|
|
text-align: left;
|
|
padding: 10px 14px;
|
|
font-size: 11px;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.05em;
|
|
color: var(--ink-faint);
|
|
background: var(--bg-3);
|
|
border-bottom: 1px solid var(--border);
|
|
font-weight: 600;
|
|
}
|
|
table td {
|
|
padding: 10px 14px;
|
|
border-bottom: 1px solid rgba(99,102,241,0.08);
|
|
color: var(--ink);
|
|
font-family: var(--font-display);
|
|
font-size: 12px;
|
|
}
|
|
table tbody tr:hover { background: rgba(99,102,241,0.05); }
|
|
table .td-center { text-align: center; }
|
|
|
|
.pill {
|
|
display: inline-block;
|
|
padding: 2px 8px;
|
|
font-size: 10px;
|
|
font-weight: 600;
|
|
border-radius: 10px;
|
|
font-family: var(--font-display);
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.04em;
|
|
}
|
|
.pill.ok { background: rgba(52,211,153,0.15); color: var(--accent-ok); border: 1px solid rgba(52,211,153,0.3); }
|
|
.pill.no { background: rgba(148,163,184,0.1); color: var(--ink-faint); border: 1px solid rgba(148,163,184,0.2); }
|
|
.pill.warn { background: rgba(251,191,36,0.15); color: var(--accent-warn); border: 1px solid rgba(251,191,36,0.3); }
|
|
|
|
/* Buttons */
|
|
.btn {
|
|
display: inline-block;
|
|
padding: 6px 12px;
|
|
font-size: 11px;
|
|
font-family: var(--font-display);
|
|
font-weight: 600;
|
|
background: transparent;
|
|
border: 1px solid var(--border);
|
|
color: var(--ink);
|
|
border-radius: 5px;
|
|
cursor: pointer;
|
|
text-decoration: none;
|
|
transition: all .15s;
|
|
letter-spacing: 0.03em;
|
|
}
|
|
.btn:hover { border-color: var(--accent); color: var(--accent); }
|
|
.btn.primary {
|
|
background: linear-gradient(135deg, rgba(99,102,241,0.15), rgba(94,234,212,0.12));
|
|
border-color: var(--accent);
|
|
color: var(--accent);
|
|
}
|
|
.btn.primary:hover {
|
|
background: linear-gradient(135deg, rgba(99,102,241,0.25), rgba(94,234,212,0.2));
|
|
box-shadow: 0 0 0 2px rgba(99,102,241,0.1);
|
|
}
|
|
.btn.danger { border-color: var(--border-hot); color: var(--accent-fail); }
|
|
.btn.danger:hover { background: rgba(239,68,68,0.1); }
|
|
.btn.sm { padding: 3px 8px; font-size: 10px; }
|
|
|
|
/* Console */
|
|
.console {
|
|
background: #030711;
|
|
border: 1px solid var(--border);
|
|
border-radius: 10px;
|
|
padding: 18px;
|
|
font-family: var(--font-display);
|
|
font-size: 12px;
|
|
color: #9fef00;
|
|
max-height: 400px;
|
|
overflow-y: auto;
|
|
white-space: pre-wrap;
|
|
line-height: 1.6;
|
|
}
|
|
.console .ts { color: var(--ink-faint); }
|
|
.console .ok { color: #34d399; }
|
|
.console .fail { color: #f87171; }
|
|
.console .info { color: #60a5fa; }
|
|
|
|
/* Card lists */
|
|
.card-list {
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
|
gap: 12px;
|
|
}
|
|
.card {
|
|
background: var(--bg-2);
|
|
border: 1px solid var(--border);
|
|
border-radius: 10px;
|
|
padding: 14px;
|
|
}
|
|
.card h4 {
|
|
margin: 0 0 6px 0;
|
|
font-family: var(--font-display);
|
|
font-size: 13px;
|
|
color: var(--accent);
|
|
}
|
|
.card .desc { color: var(--ink-dim); font-size: 12px; margin-bottom: 10px; }
|
|
.card .meta { display: flex; gap: 10px; font-size: 10px; color: var(--ink-faint); font-family: var(--font-display); }
|
|
|
|
.loading { color: var(--ink-faint); padding: 40px; text-align: center; }
|
|
.loading::after {
|
|
content: '...';
|
|
display: inline-block;
|
|
animation: dots 1.5s infinite steps(3);
|
|
}
|
|
@keyframes dots {
|
|
0%, 20% { content: ''; }
|
|
40% { content: '.'; }
|
|
60% { content: '..'; }
|
|
80%, 100% { content: '...'; }
|
|
}
|
|
|
|
/* Doctrine banner */
|
|
.doc-banner {
|
|
background: linear-gradient(90deg, rgba(99,102,241,0.08), rgba(94,234,212,0.05));
|
|
border: 1px solid var(--border);
|
|
border-left: 3px solid var(--accent);
|
|
border-radius: 8px;
|
|
padding: 14px 18px;
|
|
margin-bottom: 24px;
|
|
font-size: 13px;
|
|
color: var(--ink-dim);
|
|
line-height: 1.6;
|
|
}
|
|
.doc-banner strong { color: var(--accent); font-family: var(--font-display); }
|
|
|
|
/* Action bar */
|
|
.action-bar {
|
|
display: flex;
|
|
gap: 10px;
|
|
margin-bottom: 16px;
|
|
flex-wrap: wrap;
|
|
}
|
|
|
|
/* Responsive */
|
|
@media (max-width: 768px) {
|
|
.wrap { padding: 14px; }
|
|
header.ofh { flex-direction: column; align-items: flex-start; gap: 12px; }
|
|
.stats { grid-template-columns: repeat(2, 1fr); }
|
|
.stat-card .val { font-size: 22px; }
|
|
table { font-size: 11px; }
|
|
table th, table td { padding: 8px 10px; }
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="wrap">
|
|
|
|
<header class="ofh">
|
|
<div>
|
|
<h1>OFFICE APP <span class="tag">V33 · UNIFIED HUB</span></h1>
|
|
<div class="sub">Microsoft Graph API · 967 tenants · Automation via <code>client_credentials</code></div>
|
|
</div>
|
|
<nav>
|
|
<a href="/weval-technology-platform.html">← WTP</a>
|
|
<a href="/office-admins.html">Admins</a>
|
|
<a href="/office-senders-diag.html">Senders Diag</a>
|
|
<a href="/email-hub.html">Email Hub</a>
|
|
</nav>
|
|
</header>
|
|
|
|
<div class="doc-banner">
|
|
<strong>DOCTRINE OFFICE APP :</strong> Microsoft Graph API donne le contrôle enterprise complet (users, roles, mail, OneDrive, Teams, SharePoint, Intune, DLP, Conditional Access).
|
|
<strong>JAMAIS dire "can't with Office"</strong> — si Graph API le supporte, nous le faisons. Pattern : <code>client_credentials → Bearer → Graph v1.0 POST/PATCH</code>.
|
|
<a href="/wiki/DOCTRINE-OFFICE-APP-FULL-ENTERPRISE.md" style="color:var(--accent-2);text-decoration:underline;margin-left:8px">Doctrine complète →</a>
|
|
</div>
|
|
|
|
<!-- STATS -->
|
|
<div class="stats" id="stats">
|
|
<div class="stat-card"><div class="lbl">Accounts</div><div class="val">—</div><div class="sub">loading</div></div>
|
|
<div class="stat-card"><div class="lbl">Tenants</div><div class="val">—</div><div class="sub">loading</div></div>
|
|
<div class="stat-card ok"><div class="lbl">Automatables</div><div class="val">—</div><div class="sub">Graph creds OK</div></div>
|
|
<div class="stat-card warn"><div class="lbl">Backdoors</div><div class="val">—</div><div class="sub">recovery admins</div></div>
|
|
<div class="stat-card"><div class="lbl">Graph senders</div><div class="val">—</div><div class="sub">verified</div></div>
|
|
<div class="stat-card"><div class="lbl">Sent 24h</div><div class="val">—</div><div class="sub">Graph sends</div></div>
|
|
</div>
|
|
|
|
<!-- TABS -->
|
|
<div class="tabs">
|
|
<button class="tab active" data-tab="tenants">TENANTS</button>
|
|
<button class="tab" data-tab="actions">ACTIONS</button>
|
|
<button class="tab" data-tab="scripts">SCRIPTS & APIs</button>
|
|
<button class="tab" data-tab="intents">INTENTS CHAT</button>
|
|
<button class="tab" data-tab="workflow">WORKFLOW</button>
|
|
<button class="tab" data-tab="console">CONSOLE</button>
|
|
</div>
|
|
|
|
<!-- TENANTS PANEL -->
|
|
<div class="panel active" id="panel-tenants">
|
|
<div class="action-bar">
|
|
<button class="btn primary" onclick="bulkTestAuth()">▶ Test Graph auth (25 automatables)</button>
|
|
<button class="btn" onclick="loadTenants()">↻ Refresh</button>
|
|
<input id="filter-tenants" placeholder="filter tenant..." style="flex:1;min-width:200px;padding:6px 12px;background:var(--bg-2);border:1px solid var(--border);border-radius:5px;color:var(--ink);font-family:var(--font-display);font-size:12px" oninput="filterTenants(this.value)">
|
|
</div>
|
|
<div class="tbl-wrap">
|
|
<div class="tbl-head">
|
|
<h3>TENANTS — office_accounts</h3>
|
|
<span class="info" id="tenants-info">—</span>
|
|
</div>
|
|
<div style="max-height:500px;overflow-y:auto">
|
|
<table id="tenants-table">
|
|
<thead>
|
|
<tr>
|
|
<th>TENANT DOMAIN</th>
|
|
<th>APP_ID</th>
|
|
<th class="td-center">SECRET</th>
|
|
<th class="td-center">USERS</th>
|
|
<th class="td-center">STATUS</th>
|
|
<th>ACTIONS</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody id="tenants-body">
|
|
<tr><td colspan="6" class="loading">Loading tenants</td></tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- ACTIONS PANEL -->
|
|
<div class="panel" id="panel-actions">
|
|
<div class="card-list">
|
|
<div class="card">
|
|
<h4>🔐 Test Graph auth (bulk)</h4>
|
|
<div class="desc">Teste l'authentification OAuth2 client_credentials sur les 25 tenants automatables. Durée ~30s.</div>
|
|
<button class="btn primary" onclick="bulkTestAuth()">▶ Launch</button>
|
|
</div>
|
|
<div class="card">
|
|
<h4>👥 List users (single tenant)</h4>
|
|
<div class="desc">Liste 50 users d'un tenant via Graph API <code>/users</code>.</div>
|
|
<input id="listuser-tenant" placeholder="tenant.onmicrosoft.com" style="width:100%;padding:6px;background:var(--bg-3);border:1px solid var(--border);border-radius:5px;color:var(--ink);font-family:var(--font-display);font-size:11px;margin-bottom:8px">
|
|
<button class="btn" onclick="listUsers()">▶ List</button>
|
|
</div>
|
|
<div class="card">
|
|
<h4>🔑 Check permissions</h4>
|
|
<div class="desc">Vérifie User.Read.All + RoleManagement.Read.Directory sur un tenant.</div>
|
|
<input id="checkperm-tenant" placeholder="tenant.onmicrosoft.com" style="width:100%;padding:6px;background:var(--bg-3);border:1px solid var(--border);border-radius:5px;color:var(--ink);font-family:var(--font-display);font-size:11px;margin-bottom:8px">
|
|
<button class="btn" onclick="checkPerms()">▶ Check</button>
|
|
</div>
|
|
<div class="card">
|
|
<h4>🛡️ Backdoor plan</h4>
|
|
<div class="desc">Plan V96.23 : UPN + role + license pour 34 tenants. API <code>/office-recovery.php?action=plan</code></div>
|
|
<a class="btn" href="/api/office-recovery.php?action=plan" target="_blank">▶ View plan JSON</a>
|
|
</div>
|
|
<div class="card">
|
|
<h4>📧 Graph senders status</h4>
|
|
<div class="desc">Verified senders + recent send log (table <code>admin.graph_send_log</code>).</div>
|
|
<a class="btn" href="/office-senders-diag.html">▶ Open diag</a>
|
|
</div>
|
|
<div class="card" id="blade-remote-card" style="border-color:rgba(52,211,153,0.35)">
|
|
<h4 style="color:#5eead4">⚡ Blade Remote · Chrome session</h4>
|
|
<div class="desc">Yacine a Chrome ouvert sur Razer Blade avec tous portails loggé-in. Pilote via MCP + CDP. <strong>ZÉRO manuel, ZÉRO relogin.</strong></div>
|
|
<div style="display:flex;gap:6px;flex-wrap:wrap">
|
|
<a class="btn" href="/api/blade-status.php" target="_blank">▶ Status</a>
|
|
<a class="btn" href="/blade-hub.html" target="_blank">▶ Hub</a>
|
|
<a class="btn" href="/wiki/DOCTRINE-BLADE-IA-REMOTE.md" target="_blank">▶ Doctrine</a>
|
|
</div>
|
|
<div class="meta" style="margin-top:8px"><span>17 MCP tools · port 8765</span></div>
|
|
</div>
|
|
<div class="card">
|
|
<h4>♻️ Azure AD re-register</h4>
|
|
<div class="desc">Re-register app pour tenants avec secrets expirés. API <code>/azure-reregister-api.php</code></div>
|
|
<a class="btn" href="/api/azure-reregister-api.php" target="_blank">▶ View API</a>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- SCRIPTS PANEL -->
|
|
<div class="panel" id="panel-scripts">
|
|
<div class="tbl-wrap" style="margin-bottom:18px">
|
|
<div class="tbl-head"><h3>APIs PHP — /var/www/html/api/office-*.php</h3></div>
|
|
<div id="scripts-apis" class="card-list" style="padding:18px"></div>
|
|
</div>
|
|
<div class="tbl-wrap">
|
|
<div class="tbl-head"><h3>DB tables — 20 tables Office/Graph</h3></div>
|
|
<div id="scripts-tables" style="padding:18px">
|
|
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(240px,1fr));gap:8px;font-family:var(--font-display);font-size:11px">
|
|
<span class="pill no">admin.office_accounts</span>
|
|
<span class="pill no">admin.office_backdoors</span>
|
|
<span class="pill no">admin.office_connectors</span>
|
|
<span class="pill no">admin.office_credentials</span>
|
|
<span class="pill no">admin.office_domains</span>
|
|
<span class="pill no">admin.office_providers</span>
|
|
<span class="pill no">admin.office_scripts</span>
|
|
<span class="pill no">admin.office_users</span>
|
|
<span class="pill no">admin.office_workflow_steps</span>
|
|
<span class="pill no">admin.office365_accounts</span>
|
|
<span class="pill no">admin.o365_accounts</span>
|
|
<span class="pill no">admin.o365_accounts_local</span>
|
|
<span class="pill no">admin.o365_domains</span>
|
|
<span class="pill no">admin.o365_tenants</span>
|
|
<span class="pill no">admin.graph_accounts</span>
|
|
<span class="pill no">admin.graph_inbox_results</span>
|
|
<span class="pill no">admin.graph_mail_accounts</span>
|
|
<span class="pill no">admin.graph_send_log</span>
|
|
<span class="pill no">admin.graph_tenants</span>
|
|
<span class="pill no">admin.graph_verified_senders</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- INTENTS CHAT PANEL -->
|
|
<div class="panel" id="panel-intents">
|
|
<div class="doc-banner">
|
|
<strong>CHAT WEVIA :</strong> tape un trigger dans le chat WEVIA Master pour activer l'intent. Les intents ci-dessous sont <code>wired-pending</code> — prêts à activer dans le registry.
|
|
</div>
|
|
<div class="tbl-wrap">
|
|
<div class="tbl-head">
|
|
<h3>INTENTS Office/Graph/Azure — wired-pending</h3>
|
|
<span class="info" id="intents-info">—</span>
|
|
</div>
|
|
<div id="intents-body" style="padding:18px" class="card-list"></div>
|
|
</div>
|
|
</div>
|
|
|
|
|
|
<!-- WORKFLOW PANEL V34 -->
|
|
<div class="panel" id="panel-workflow">
|
|
<div class="action-bar">
|
|
<button class="btn primary" onclick="loadWorkflow()">▶ Load workflow</button>
|
|
<button class="btn" onclick="loadWorkflow()">↻ Refresh</button>
|
|
</div>
|
|
<div class="stats" id="wf-stats" style="margin-bottom:18px">
|
|
<div class="stat-card"><div class="lbl">Total</div><div class="val" id="wf-total">—</div><div class="sub">loading</div></div>
|
|
<div class="stat-card ok"><div class="lbl">Active</div><div class="val" id="wf-active">—</div><div class="sub">status active</div></div>
|
|
<div class="stat-card warn"><div class="lbl">Warming</div><div class="val" id="wf-warming">—</div><div class="sub">warmup</div></div>
|
|
<div class="stat-card"><div class="lbl">Pending</div><div class="val" id="wf-pending">—</div><div class="sub">pending</div></div>
|
|
<div class="stat-card fail"><div class="lbl">Blocked</div><div class="val" id="wf-blocked">—</div><div class="sub">blocked</div></div>
|
|
</div>
|
|
<div class="tbl-wrap" style="margin-bottom:18px">
|
|
<div class="tbl-head">
|
|
<h3>WORKFLOW STEPS — 8 steps Register → Live</h3>
|
|
<span class="info" id="wf-info">—</span>
|
|
</div>
|
|
<table id="wf-steps-table">
|
|
<thead>
|
|
<tr>
|
|
<th>STEP #</th>
|
|
<th>NAME</th>
|
|
<th class="td-center">TOTAL</th>
|
|
<th class="td-center">ACTIVE</th>
|
|
<th class="td-center">WARMING</th>
|
|
<th class="td-center">PENDING</th>
|
|
<th class="td-center">BLOCKED</th>
|
|
<th class="td-center">%</th>
|
|
<th>ACTIONS</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody id="wf-steps-body">
|
|
<tr><td colspan="9" class="loading">Click Load workflow</td></tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
<div class="tbl-wrap">
|
|
<div class="tbl-head">
|
|
<h3>ACCOUNTS (drill-down par step)</h3>
|
|
<span class="info" id="wf-accounts-info">Click un step pour voir accounts</span>
|
|
</div>
|
|
<table id="wf-accounts-table" style="display:none">
|
|
<thead>
|
|
<tr>
|
|
<th>#ID</th>
|
|
<th>TENANT DOMAIN</th>
|
|
<th>ADMIN EMAIL</th>
|
|
<th>STEP</th>
|
|
<th>STATUS</th>
|
|
<th class="td-center">LICENSE</th>
|
|
<th class="td-center">MFA</th>
|
|
<th>ACTIONS</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody id="wf-accounts-body"></tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
<!-- /WORKFLOW PANEL V34 -->
|
|
|
|
|
|
<!-- CONSOLE PANEL -->
|
|
<div class="panel" id="panel-console">
|
|
<div class="tbl-head" style="background:var(--bg-2);border:1px solid var(--border);border-radius:10px 10px 0 0;margin-bottom:0">
|
|
<h3>LIVE CONSOLE</h3>
|
|
<button class="btn sm" onclick="clearConsole()">clear</button>
|
|
</div>
|
|
<div class="console" id="console"><span class="ts">[ready]</span> OFFICE APP hub initialized. Graph API ready.\n</div>
|
|
</div>
|
|
|
|
</div>
|
|
|
|
<script>
|
|
'use strict';
|
|
|
|
// ==========================================================================
|
|
// OFFICE APP — V33 Hub unifié
|
|
// API base: /api/office-app.php
|
|
// ==========================================================================
|
|
|
|
const API = '/api/office-app.php';
|
|
let _tenants = []; // cache
|
|
|
|
function log(msg, cls='') {
|
|
const c = document.getElementById('console');
|
|
const ts = new Date().toISOString().split('T')[1].slice(0,8);
|
|
const line = document.createElement('div');
|
|
line.innerHTML = `<span class="ts">[${ts}]</span> <span class="${cls}">${msg}</span>`;
|
|
c.appendChild(line);
|
|
c.scrollTop = c.scrollHeight;
|
|
}
|
|
|
|
function clearConsole() {
|
|
document.getElementById('console').innerHTML = '<span class="ts">[cleared]</span>\n';
|
|
}
|
|
|
|
async function loadOverview() {
|
|
try {
|
|
const r = await fetch(API + '?action=overview');
|
|
const d = await r.json();
|
|
if (!d.ok) throw new Error(d.error || 'overview fail');
|
|
const s = d.stats;
|
|
const stats = document.getElementById('stats');
|
|
stats.innerHTML = `
|
|
<div class="stat-card"><div class="lbl">Accounts</div><div class="val">${s.total_accounts}</div><div class="sub">total DB</div></div>
|
|
<div class="stat-card"><div class="lbl">Tenants</div><div class="val">${s.total_tenants}</div><div class="sub">unique domains</div></div>
|
|
<div class="stat-card ok"><div class="lbl">Automatables</div><div class="val">${s.automatable_tenants}</div><div class="sub">Graph creds OK</div></div>
|
|
<div class="stat-card ${s.backdoors_registered > 0 ? 'ok' : 'fail'}"><div class="lbl">Backdoors</div><div class="val">${s.backdoors_registered}</div><div class="sub">coverage ${s.coverage_backdoor_pct}%</div></div>
|
|
<div class="stat-card"><div class="lbl">Graph senders</div><div class="val">${s.graph_verified_senders}</div><div class="sub">verified</div></div>
|
|
<div class="stat-card"><div class="lbl">Sent 24h</div><div class="val">${s.graph_sent_last_24h}</div><div class="sub">Graph sends</div></div>
|
|
`;
|
|
log(`overview OK — ${s.total_accounts} accounts, ${s.automatable_tenants} automatables, ${s.backdoors_registered} backdoors`, 'ok');
|
|
} catch (e) {
|
|
log('overview FAIL: ' + e.message, 'fail');
|
|
}
|
|
}
|
|
|
|
async function loadTenants() {
|
|
const body = document.getElementById('tenants-body');
|
|
body.innerHTML = '<tr><td colspan="6" class="loading">Loading</td></tr>';
|
|
try {
|
|
const r = await fetch(API + '?action=tenants_list');
|
|
const d = await r.json();
|
|
if (!d.ok) throw new Error(d.error || 'tenants_list fail');
|
|
_tenants = d.tenants;
|
|
document.getElementById('tenants-info').textContent = `${d.count} tenants`;
|
|
renderTenants(_tenants);
|
|
log(`tenants loaded: ${d.count}`, 'ok');
|
|
} catch (e) {
|
|
body.innerHTML = `<tr><td colspan="6" class="loading">ERR: ${e.message}</td></tr>`;
|
|
log('tenants FAIL: ' + e.message, 'fail');
|
|
}
|
|
}
|
|
|
|
function renderTenants(rows) {
|
|
const body = document.getElementById('tenants-body');
|
|
if (!rows.length) { body.innerHTML = '<tr><td colspan="6" class="loading">no tenants</td></tr>'; return; }
|
|
const first = rows.slice(0, 200); // render first 200 for perf
|
|
body.innerHTML = first.map(t => {
|
|
const app = t.app_id ? t.app_id.slice(0, 16) + '…' : '<span style="color:var(--ink-faint)">—</span>';
|
|
const secret = t.has_secret == 1 ? '<span class="pill ok">YES</span>' : '<span class="pill no">NO</span>';
|
|
const auto = (t.app_id && t.has_secret == 1 && t.tenant_id) ? '<span class="pill ok">AUTO</span>' : '<span class="pill no">manual</span>';
|
|
const actions = (t.app_id && t.has_secret == 1 && t.tenant_id)
|
|
? `<button class="btn sm" onclick="testAuth('${t.tenant_domain}')">auth</button> <button class="btn sm" onclick="listUsersFor('${t.tenant_domain}')">users</button> <button class="btn sm" onclick="checkPermsFor('${t.tenant_domain}')">perms</button>`
|
|
: '<span style="color:var(--ink-faint);font-size:11px">manual-only</span>';
|
|
return `<tr data-tenant="${t.tenant_domain}">
|
|
<td style="color:var(--accent-2)">${t.tenant_domain}</td>
|
|
<td>${app}</td>
|
|
<td class="td-center">${secret}</td>
|
|
<td class="td-center">${t.users}</td>
|
|
<td class="td-center">${auto}</td>
|
|
<td>${actions}</td>
|
|
</tr>`;
|
|
}).join('');
|
|
if (rows.length > 200) {
|
|
body.innerHTML += `<tr><td colspan="6" class="loading" style="padding:16px;color:var(--ink-faint)">+ ${rows.length - 200} autres (filtrer pour voir)</td></tr>`;
|
|
}
|
|
}
|
|
|
|
function filterTenants(q) {
|
|
q = q.toLowerCase();
|
|
if (!q) return renderTenants(_tenants);
|
|
renderTenants(_tenants.filter(t => (t.tenant_domain || '').toLowerCase().includes(q)));
|
|
}
|
|
|
|
async function testAuth(tenant) {
|
|
log(`→ testAuth(${tenant})...`);
|
|
try {
|
|
const r = await fetch(`${API}?action=test_auth&tenant=${encodeURIComponent(tenant)}`);
|
|
const d = await r.json();
|
|
if (d.ok) {
|
|
log(` ✓ ${tenant} → HTTP ${d.http}, users_readable=${d.users_readable}, users_sample=${d.users_count_sample}`, 'ok');
|
|
} else {
|
|
log(` ✗ ${tenant} → ${d.error || 'fail'} ${d.error_description || ''}`, 'fail');
|
|
}
|
|
} catch (e) { log(` ✗ ${tenant} ERR: ${e.message}`, 'fail'); }
|
|
}
|
|
|
|
async function listUsersFor(tenant) {
|
|
log(`→ listUsers(${tenant})...`);
|
|
try {
|
|
const r = await fetch(`${API}?action=list_users&tenant=${encodeURIComponent(tenant)}`);
|
|
const d = await r.json();
|
|
if (d.ok) {
|
|
log(` ✓ ${tenant} → ${d.count} users`, 'ok');
|
|
(d.users || []).slice(0, 5).forEach(u => log(` · ${u.userPrincipalName} [${u.displayName}] enabled=${u.accountEnabled}`, 'info'));
|
|
if ((d.users || []).length > 5) log(` + ${(d.users || []).length - 5} autres users...`, 'info');
|
|
} else {
|
|
log(` ✗ ${tenant} → ${d.error}`, 'fail');
|
|
}
|
|
} catch (e) { log(` ✗ ${tenant} ERR: ${e.message}`, 'fail'); }
|
|
}
|
|
|
|
async function listUsers() {
|
|
const t = document.getElementById('listuser-tenant').value.trim();
|
|
if (t) { switchTab('console'); listUsersFor(t); }
|
|
}
|
|
|
|
async function checkPermsFor(tenant) {
|
|
log(`→ checkPerms(${tenant})...`);
|
|
try {
|
|
const r = await fetch(`${API}?action=check_perms&tenant=${encodeURIComponent(tenant)}`);
|
|
const d = await r.json();
|
|
if (d.ok) {
|
|
log(` ✓ ${tenant} perms:`, 'ok');
|
|
Object.entries(d.perms).forEach(([k, v]) => log(` ${k}: ${v}`, v === true || (typeof v === 'number' && v > 0) ? 'ok' : 'fail'));
|
|
} else {
|
|
log(` ✗ ${tenant} → ${d.error}`, 'fail');
|
|
}
|
|
} catch (e) { log(` ✗ ${tenant} ERR: ${e.message}`, 'fail'); }
|
|
}
|
|
|
|
async function checkPerms() {
|
|
const t = document.getElementById('checkperm-tenant').value.trim();
|
|
if (t) { switchTab('console'); checkPermsFor(t); }
|
|
}
|
|
|
|
async function bulkTestAuth() {
|
|
switchTab('console');
|
|
log('▶ BULK TEST AUTH — 25 automatable tenants', 'info');
|
|
try {
|
|
const r = await fetch(`${API}?action=test_all_auth`);
|
|
const d = await r.json();
|
|
if (!d.ok) throw new Error(d.error || 'fail');
|
|
log(`tested=${d.tested} auth_ok=${d.graph_auth_ok} auth_fail=${d.graph_auth_fail}`, 'ok');
|
|
d.results.forEach(res => {
|
|
const cls = res.auth_ok ? 'ok' : 'fail';
|
|
const icon = res.auth_ok ? '✓' : '✗';
|
|
log(` ${icon} ${res.tenant} → HTTP ${res.http}${res.error ? ' · ' + res.error : ''}`, cls);
|
|
});
|
|
log(`→ ${d.graph_auth_ok}/${d.tested} tenants prêts pour backdoor automation`, 'info');
|
|
await loadOverview();
|
|
} catch (e) { log('bulk FAIL: ' + e.message, 'fail'); }
|
|
}
|
|
|
|
async function loadIntents() {
|
|
const body = document.getElementById('intents-body');
|
|
body.innerHTML = '<div class="loading">Loading</div>';
|
|
try {
|
|
const r = await fetch(API + '?action=intents_list');
|
|
const d = await r.json();
|
|
if (!d.ok) throw new Error(d.error);
|
|
document.getElementById('intents-info').textContent = `${d.count} intents`;
|
|
body.innerHTML = d.intents.map(i => {
|
|
const trigs = (i.triggers_sample || []).map(t => `<code style="background:var(--bg-3);padding:1px 5px;border-radius:3px;font-size:10px">${t.slice(0, 40)}${t.length > 40 ? '…' : ''}</code>`).join(' ');
|
|
return `<div class="card">
|
|
<h4>${i.name}</h4>
|
|
<div class="desc">Triggers: ${trigs || '<i style="color:var(--ink-faint)">—</i>'}</div>
|
|
<div class="meta">
|
|
<span>${i.file}</span>
|
|
<span>${(i.size/1024).toFixed(1)}KB</span>
|
|
</div>
|
|
</div>`;
|
|
}).join('');
|
|
} catch (e) {
|
|
body.innerHTML = `<div class="loading">ERR: ${e.message}</div>`;
|
|
}
|
|
}
|
|
|
|
async function loadApis() {
|
|
const body = document.getElementById('scripts-apis');
|
|
try {
|
|
const r = await fetch(API + '?action=overview');
|
|
const d = await r.json();
|
|
const apis = d.capabilities.apis_php || [];
|
|
body.innerHTML = apis.map(a => {
|
|
const name = a.replace('.php','');
|
|
return `<div class="card">
|
|
<h4>${name}</h4>
|
|
<div class="desc" style="font-size:11px">API PHP · /api/${a}</div>
|
|
<a class="btn sm" href="/api/${a}" target="_blank">open</a>
|
|
</div>`;
|
|
}).join('');
|
|
} catch (e) {
|
|
body.innerHTML = `<div class="loading">ERR: ${e.message}</div>`;
|
|
}
|
|
}
|
|
|
|
function switchTab(name) {
|
|
document.querySelectorAll('.tab').forEach(t => t.classList.toggle('active', t.dataset.tab === name));
|
|
document.querySelectorAll('.panel').forEach(p => p.classList.toggle('active', p.id === 'panel-' + name));
|
|
if (name === 'intents' && !document.getElementById('intents-body').children.length) loadIntents();
|
|
if (name === 'scripts' && !document.getElementById('scripts-apis').children.length) loadApis();
|
|
if (name === 'workflow' && !document.getElementById('wf-steps-body').children.length) loadWorkflow();
|
|
if (name === 'workflow' && document.getElementById('wf-steps-body').children.length === 1) loadWorkflow();
|
|
|
|
}
|
|
document.querySelectorAll('.tab').forEach(t => t.addEventListener('click', () => switchTab(t.dataset.tab)));
|
|
|
|
|
|
// V34 — WORKFLOW ACTIONS
|
|
async function loadWorkflow() {
|
|
try {
|
|
const r = await fetch(API + '?action=workflow_overview');
|
|
const d = await r.json();
|
|
if (!d.ok) throw new Error(d.error || 'fail');
|
|
|
|
// Aggregate status totals
|
|
const bs = {};
|
|
(d.by_status || []).forEach(s => bs[s.status] = parseInt(s.n));
|
|
document.getElementById('wf-total').textContent = d.total_accounts;
|
|
document.getElementById('wf-active').textContent = bs.active || 0;
|
|
document.getElementById('wf-warming').textContent = bs.warming || 0;
|
|
document.getElementById('wf-pending').textContent = bs.pending || 0;
|
|
document.getElementById('wf-blocked').textContent = bs.blocked || 0;
|
|
|
|
const body = document.getElementById('wf-steps-body');
|
|
document.getElementById('wf-info').textContent = (d.by_step||[]).length + ' steps';
|
|
body.innerHTML = (d.by_step || []).map(s => {
|
|
const pct = d.total_accounts ? ((s.total / d.total_accounts) * 100).toFixed(1) : 0;
|
|
return `<tr>
|
|
<td style="color:var(--accent-2)"><strong>${s.current_step}</strong></td>
|
|
<td>${s.step_name}</td>
|
|
<td class="td-center">${s.total}</td>
|
|
<td class="td-center"><span class="pill ok">${s.active}</span></td>
|
|
<td class="td-center">${s.warming || 0}</td>
|
|
<td class="td-center">${s.pending || 0}</td>
|
|
<td class="td-center">${s.blocked > 0 ? '<span class="pill warn">' + s.blocked + '</span>' : 0}</td>
|
|
<td class="td-center">${pct}%</td>
|
|
<td><button class="btn sm" onclick="loadWfAccounts(${s.current_step})">drill</button></td>
|
|
</tr>`;
|
|
}).join('');
|
|
log('workflow loaded: ' + d.total_accounts + ' accounts', 'ok');
|
|
} catch (e) {
|
|
log('workflow FAIL: ' + e.message, 'fail');
|
|
}
|
|
}
|
|
|
|
async function loadWfAccounts(step) {
|
|
log('drill step=' + step + '...');
|
|
try {
|
|
const r = await fetch(API + '?action=workflow_accounts&step=' + step + '&limit=100');
|
|
const d = await r.json();
|
|
if (!d.ok) throw new Error(d.error);
|
|
document.getElementById('wf-accounts-info').textContent = 'step=' + step + ' · ' + d.count + ' accounts';
|
|
document.getElementById('wf-accounts-table').style.display = '';
|
|
const body = document.getElementById('wf-accounts-body');
|
|
body.innerHTML = (d.accounts || []).map(a => {
|
|
const stPill = a.status === 'active' ? 'ok' : (a.status === 'pending' ? 'no' : (a.status === 'blocked' ? 'warn' : 'no'));
|
|
return `<tr data-aid="${a.id}">
|
|
<td>${a.id}</td>
|
|
<td style="color:var(--accent-2)">${a.tenant_domain || ''}</td>
|
|
<td>${a.admin_email || '<span style=color:var(--ink-faint)>—</span>'}</td>
|
|
<td>${a.current_step} ${a.step_name}</td>
|
|
<td><span class="pill ${stPill}">${a.status || 'null'}</span></td>
|
|
<td class="td-center">${a.has_license ? '✓' : '—'}</td>
|
|
<td class="td-center">${a.has_mfa ? '✓' : '—'}</td>
|
|
<td>
|
|
<button class="btn sm" onclick="wfAdv(${a.id})" title="advance step">▶</button>
|
|
<button class="btn sm" onclick="wfRet(${a.id})" title="retreat step">◀</button>
|
|
</td>
|
|
</tr>`;
|
|
}).join('');
|
|
log(' ' + d.count + ' accounts for step ' + step, 'ok');
|
|
} catch (e) {
|
|
log('drill FAIL: ' + e.message, 'fail');
|
|
}
|
|
}
|
|
|
|
async function wfAdv(aid) {
|
|
try {
|
|
const fd = new FormData();
|
|
fd.append('aid', aid);
|
|
const r = await fetch(API + '?action=workflow_advance', { method: 'POST', body: fd });
|
|
const d = await r.json();
|
|
if (d.ok) log('advance #' + aid + ': step ' + d.from_step + ' -> ' + d.to_step + ' (' + d.new_step_name + ')', 'ok');
|
|
else log('advance #' + aid + ' FAIL: ' + d.error, 'fail');
|
|
} catch (e) { log('advance ERR: ' + e.message, 'fail'); }
|
|
}
|
|
|
|
async function wfRet(aid) {
|
|
try {
|
|
const fd = new FormData();
|
|
fd.append('aid', aid);
|
|
const r = await fetch(API + '?action=workflow_retreat', { method: 'POST', body: fd });
|
|
const d = await r.json();
|
|
if (d.ok) log('retreat #' + aid + ': step ' + d.from_step + ' -> ' + d.to_step + ' (' + d.new_step_name + ')', 'ok');
|
|
else log('retreat #' + aid + ' FAIL: ' + d.error, 'fail');
|
|
} catch (e) { log('retreat ERR: ' + e.message, 'fail'); }
|
|
}
|
|
|
|
|
|
// init
|
|
loadOverview();
|
|
loadTenants();
|
|
|
|
</script>
|
|
|
|
<script src="/api/a11y-auto-enhancer.js" defer></script>
|
|
<!-- WTP_UDOCK_V1 (Opus 21-avr t32b4) --><script src="/wtp-unified-dock.js" defer></script>
|
|
<script src="/opus-antioverlap-doctrine.js?v=1776776094" defer></script>
|
|
</body>
|
|
</html>
|