Files
ObsiGate/frontend/js/admin.js
T
bruno 01453bce5f
CI / lint (push) Successful in 38s
CI / security (push) Successful in 26s
CI / test (push) Successful in 50s
CI / build (push) Successful in 22s
CI / e2e (push) Successful in 6m7s
Desktop Build / build-windows (push) Canceled after 0s
Desktop Build / build-linux (push) Canceled after 0s
feat(admin): navigation par sections sticky + support complet des themes
Navigation/ergonomie:
- barre sticky de pilules (Stats, Audit, Backups, Utilisateurs) sous le header
- scrollspy (IntersectionObserver) + scroll fluide au clic dans admin.js
- ancres + scroll-margin-top sur chaque section, layout responsive <600px

Themes:
- admin.html importe et appelle initThemes() (themes.js) au boot: la page
  reprend le theme/mode sauvegarde dans l'app (localStorage meme-origine)
- script anti-FOUC en <head> applique vite le mode avant le CSS
2026-09-07 21:45:50 -04:00

545 lines
20 KiB
JavaScript

// frontend/js/admin.js — Admin Dashboard frontend module (ROADMAP #71)
//
// Standalone ES module loaded by frontend/admin.html.
// Public API:
// init() — DOMContentLoaded entry point. Verifies auth, fires initial loads.
// connectSSE() — Opens EventSource on /api/admin/stream; updates widgets on every "stats" event.
// loadStatsOnce() — Initial GET /api/admin/stats (snapshot before SSE delivers first frame).
// loadAuditLogs(filters) — GET /api/admin/audit with optional {user, action}.
// loadBackupStats() — GET /api/admin/backup-stats.
// renderStatsWidget(stats) — Paint CPU/RAM/Disk/Uptime/Sessions cards.
// renderAuditTable(entries) — Paint audit log table.
// renderBackups(byVault) — Paint per-vault backup bars.
// getAuthHeaders() — Read Bearer token from sessionStorage (same source as auth.js).
// formatBytes(mb) — Human-readable size formatter.
// formatUptime(seconds) — "Xd Yh Zm" formatter.
// severityColor(pct) — "success" | "warning" | "danger" based on thresholds.
import { api, AdminPanel } from "./auth.js";
import { state } from "./state.js";
import { escapeHtml, safeCreateIcons } from "./utils.js";
import { t, getLocale } from "./i18n.js";
// ── Module state ─────────────────────────────────────────────────────────
let _eventSource = null;
let _pollTimer = null;
let _auditFilters = { user: "", action: "" };
// ── Helpers ──────────────────────────────────────────────────────────────
/**
* Read the current Bearer token from sessionStorage (same key auth.js uses).
* Returns an `{Authorization: "Bearer ..."}` object or null.
*/
export function getAuthHeaders() {
try {
const token = sessionStorage.getItem("obsigate_access_token");
if (!token) return null;
return { Authorization: "Bearer " + token };
} catch {
return null;
}
}
/** Format a size in MB as a human-readable string. */
export function formatBytes(mb) {
if (mb == null || isNaN(mb)) return "—";
if (mb < 1) return `${Math.round(mb * 1024)} KB`;
if (mb < 1024) return `${mb.toFixed(1)} MB`;
return `${(mb / 1024).toFixed(2)} GB`;
}
/** Format an uptime in seconds as "Xd Yh Zm" (or "Xh Ym" / "Xm"). */
export function formatUptime(seconds) {
if (seconds == null || isNaN(seconds) || seconds < 0) return "—";
const s = Math.floor(seconds);
const d = Math.floor(s / 86400);
const h = Math.floor((s % 86400) / 3600);
const m = Math.floor((s % 3600) / 60);
if (d > 0) return `${d}d ${h}h ${m}m`;
if (h > 0) return `${h}h ${m}m`;
return `${m}m`;
}
/**
* Map a percentage to a CSS severity token.
* <70 → success (green)
* <90 → warning (orange)
* ≥90 → danger (red)
*/
export function severityColor(pct) {
if (pct == null || isNaN(pct)) return "success";
if (pct >= 90) return "danger";
if (pct >= 70) return "warning";
return "success";
}
/** Format an ISO timestamp with the user's locale. */
function formatDate(iso) {
if (!iso) return "—";
try {
return new Date(iso).toLocaleString(
getLocale() === "fr" ? "fr-FR" : "en-US"
);
} catch {
return iso;
}
}
/** Discreet error helper — never crashes the page. */
function _showInlineError(containerId, msg) {
const el = document.getElementById(containerId);
if (!el) return;
el.innerHTML = `<div class="admin-inline-error" style="color:var(--danger);padding:8px 12px;font-size:0.85rem;">${escapeHtml(msg)}</div>`;
}
// ── Stats widget ─────────────────────────────────────────────────────────
/**
* Paint the 5 stats cards.
* @param {object} stats {cpu_pct, mem_used_mb, mem_total_mb, disk_used_gb, disk_total_gb, uptime_seconds, active_sessions}
*/
export function renderStatsWidget(stats) {
if (!stats) return;
const grid = document.getElementById("admin-stats-grid");
if (!grid) return;
const cpuPct = stats.cpu_pct ?? 0;
const memPct = stats.mem_total_mb > 0 ? (stats.mem_used_mb / stats.mem_total_mb) * 100 : 0;
const diskPct = stats.disk_total_gb > 0 ? (stats.disk_used_gb / stats.disk_total_gb) * 100 : 0;
const cpuSev = severityColor(cpuPct);
const memSev = severityColor(memPct);
const diskSev = severityColor(diskPct);
const memTotal = stats.mem_total_mb ?? 0;
const diskTotal = stats.disk_total_gb ?? 0;
grid.innerHTML = `
<div class="admin-stat-card" data-sev="${cpuSev}">
<div class="admin-stat-label">${escapeHtml(t("admin.stats_cpu"))}</div>
<div class="admin-stat-value">${cpuPct.toFixed(1)}<span class="admin-stat-unit">%</span></div>
<div class="admin-stat-bar"><div class="admin-stat-bar-fill" style="width:${Math.min(cpuPct, 100).toFixed(1)}%;background:var(--${cpuSev});"></div></div>
</div>
<div class="admin-stat-card" data-sev="${memSev}">
<div class="admin-stat-label">${escapeHtml(t("admin.stats_memory"))}</div>
<div class="admin-stat-value">${stats.mem_used_mb?.toFixed(0) ?? "—"}<span class="admin-stat-unit"> / ${memTotal.toFixed(0)} MB</span></div>
<div class="admin-stat-bar"><div class="admin-stat-bar-fill" style="width:${Math.min(memPct, 100).toFixed(1)}%;background:var(--${memSev});"></div></div>
</div>
<div class="admin-stat-card" data-sev="${diskSev}">
<div class="admin-stat-label">${escapeHtml(t("admin.stats_disk"))}</div>
<div class="admin-stat-value">${stats.disk_used_gb?.toFixed(1) ?? "—"}<span class="admin-stat-unit"> / ${diskTotal.toFixed(1)} GB</span></div>
<div class="admin-stat-bar"><div class="admin-stat-bar-fill" style="width:${Math.min(diskPct, 100).toFixed(1)}%;background:var(--${diskSev});"></div></div>
</div>
<div class="admin-stat-card">
<div class="admin-stat-label">${escapeHtml(t("admin.stats_uptime"))}</div>
<div class="admin-stat-value" style="font-size:1.4rem;">${escapeHtml(formatUptime(stats.uptime_seconds))}</div>
</div>
<div class="admin-stat-card">
<div class="admin-stat-label">${escapeHtml(t("admin.stats_sessions"))}</div>
<div class="admin-stat-value">${stats.active_sessions ?? 0}</div>
</div>
`;
// Update footer timestamp
const ts = stats.timestamp ? new Date(stats.timestamp) : new Date();
const stamp = document.getElementById("admin-stats-timestamp");
if (stamp) {
stamp.textContent = t("admin.stats_refresh", { time: ts.toLocaleTimeString(getLocale() === "fr" ? "fr-FR" : "en-US") });
}
}
/** Fetch /api/admin/stats and paint. */
export async function loadStatsOnce() {
try {
const data = await api("/api/admin/stats");
renderStatsWidget(data);
} catch (err) {
_showInlineError("admin-stats-grid", err.message || "stats load failed");
}
}
/**
* Open a Server-Sent Events connection on /api/admin/stream.
* The endpoint emits `event: stats\ndata: {...}` every 5 seconds.
*
* The browser's EventSource doesn't support custom headers, so we rely on
* the httpOnly cookie (set by /api/auth/login with samesite=lax) to authenticate.
* `withCredentials: true` ensures the cookie is sent.
*
* If the connection fails to open (4xx/5xx or network), we fall back to
* periodic polling of /api/admin/stats every 5s so the UI keeps updating.
*/
export function connectSSE() {
if (_eventSource) {
try { _eventSource.close(); } catch { /* */ }
_eventSource = null;
}
if (_pollTimer) {
clearInterval(_pollTimer);
_pollTimer = null;
}
let sseReady = false;
try {
_eventSource = new EventSource("/api/admin/stream", { withCredentials: true });
_eventSource.addEventListener("stats", (ev) => {
sseReady = true;
try {
const data = JSON.parse(ev.data);
renderStatsWidget(data);
} catch (err) {
console.warn("admin SSE parse error", err);
}
});
_eventSource.onopen = () => {
sseReady = true;
// Stop the polling fallback once SSE works.
if (_pollTimer) { clearInterval(_pollTimer); _pollTimer = null; }
};
_eventSource.onerror = () => {
// EventSource auto-reconnects. Only start polling fallback if we
// never received the first event yet (e.g. 401/403/network blocked).
if (!sseReady) {
_startPollingFallback();
}
console.warn("admin SSE error (will retry)");
};
} catch (err) {
console.warn("admin SSE init failed", err);
_startPollingFallback();
}
// Kick off a single initial fetch right away so the UI doesn't show
// zeros for 5 seconds waiting for the first SSE event.
loadStatsOnce();
}
function _startPollingFallback() {
if (_pollTimer) return;
console.warn("admin: falling back to polling /api/admin/stats every 5s");
_pollTimer = setInterval(() => {
loadStatsOnce().catch(() => { /* ignore — error already shown */ });
}, 5000);
}
/** Close the SSE connection if any (useful before navigation). */
export function disconnectSSE() {
if (_eventSource) {
try { _eventSource.close(); } catch { /* */ }
_eventSource = null;
}
if (_pollTimer) {
clearInterval(_pollTimer);
_pollTimer = null;
}
}
// ── Audit log ────────────────────────────────────────────────────────────
/**
* Render the audit log table.
* @param {Array<object>} entries Array of audit entries (timestamp, action, username, vault, ip, ...).
*/
export function renderAuditTable(entries) {
const tbody = document.getElementById("admin-audit-tbody");
const empty = document.getElementById("admin-audit-empty");
if (!tbody) return;
if (!entries || !entries.length) {
tbody.innerHTML = "";
if (empty) empty.style.display = "";
return;
}
if (empty) empty.style.display = "none";
tbody.innerHTML = entries.map((e) => {
const ts = formatDate(e.timestamp);
const action = escapeHtml(e.action || "—");
const user = escapeHtml(e.username || e.user || "—");
const vault = escapeHtml(e.vault || "—");
const ip = escapeHtml(e.ip || "—");
return `<tr>
<td>${escapeHtml(ts)}</td>
<td><code>${action}</code></td>
<td>${user}</td>
<td>${vault}</td>
<td>${ip}</td>
</tr>`;
}).join("");
}
/**
* Fetch /api/admin/audit with optional filters.
* @param {{user?: string, action?: string}} filters
*/
export async function loadAuditLogs(filters = {}) {
if (filters.user !== undefined) _auditFilters.user = filters.user;
if (filters.action !== undefined) _auditFilters.action = filters.action;
const params = new URLSearchParams();
if (_auditFilters.user) params.set("user", _auditFilters.user);
if (_auditFilters.action) params.set("action", _auditFilters.action);
params.set("limit", "200");
const qs = params.toString();
try {
const data = await api(`/api/admin/audit?${qs}`);
renderAuditTable(data.entries || []);
} catch (err) {
_showInlineError("admin-audit-tbody", err.message || "audit load failed");
}
}
/** Populate the action <select> with the values we know about.
* The endpoint doesn't list all possible actions, so we hardcode the
* common ones and let the user pick "All". */
function _populateAuditActions() {
const select = document.getElementById("admin-audit-action");
if (!select) return;
const ACTIONS = [
"file_save", "file_delete", "file_view", "file_rename",
"share_create", "share_revoke", "auth_login", "auth_logout",
"auth_failed", "user_create", "user_update", "user_delete",
"backup_create", "config_update",
];
select.innerHTML =
`<option value="">${escapeHtml(t("admin.audit_all_actions"))}</option>` +
ACTIONS.map((a) => `<option value="${a}">${escapeHtml(a)}</option>`).join("");
}
// ── Backups ─────────────────────────────────────────────────────────────
/**
* Render per-vault backup bars.
* @param {object} byVault { vault: {count, size_mb} }
*/
export function renderBackups(byVault) {
const tbody = document.getElementById("admin-backups-tbody");
const empty = document.getElementById("admin-backups-empty");
if (!tbody) return;
const entries = byVault ? Object.entries(byVault) : [];
if (!entries.length) {
tbody.innerHTML = "";
if (empty) empty.style.display = "";
return;
}
if (empty) empty.style.display = "none";
// Largest vault for proportional bars
const maxSize = Math.max(...entries.map(([, v]) => v.size_mb || 0), 1);
tbody.innerHTML = entries
.sort((a, b) => (b[1].size_mb || 0) - (a[1].size_mb || 0))
.map(([vault, data]) => {
const count = data.count || 0;
const size = data.size_mb || 0;
const widthPct = Math.min((size / maxSize) * 100, 100).toFixed(1);
return `<tr>
<td>${escapeHtml(vault)}</td>
<td>${count}</td>
<td>${escapeHtml(formatBytes(size))}</td>
<td>
<div class="admin-backup-bar"><div class="admin-backup-bar-fill" style="width:${widthPct}%;"></div></div>
</td>
</tr>`;
})
.join("");
}
/** Fetch /api/admin/backup-stats and paint. */
export async function loadBackupStats() {
try {
const data = await api("/api/admin/backup-stats");
const summary = document.getElementById("admin-backups-summary");
if (summary) {
summary.innerHTML = `
<div>${escapeHtml(t("admin.backups_total", { count: data.total_backups ?? 0, size: formatBytes(data.total_size_mb) }))}</div>
<div>${escapeHtml(t("admin.backups_oldest", { days: (data.oldest_age_days ?? 0).toFixed(2) }))}</div>
<div>${escapeHtml(t("admin.backups_newest", { days: (data.newest_age_days ?? 0).toFixed(2) }))}</div>
`;
}
renderBackups(data.by_vault || {});
} catch (err) {
_showInlineError("admin-backups-tbody", err.message || "backups load failed");
}
}
// ── User management integration ─────────────────────────────────────────
/** Wire the "Manage users" button to open the existing AdminPanel modal. */
function _wireUserMgmt() {
const btn = document.getElementById("admin-users-manage-btn");
if (!btn) return;
btn.addEventListener("click", () => {
try {
AdminPanel.show();
} catch (err) {
console.error("AdminPanel.show failed", err);
}
});
}
// ── Filters wiring ──────────────────────────────────────────────────────
function _wireAuditFilters() {
const userInput = document.getElementById("admin-audit-user");
const actionSelect = document.getElementById("admin-audit-action");
const refreshBtn = document.getElementById("admin-audit-refresh");
if (refreshBtn) {
refreshBtn.addEventListener("click", () => loadAuditLogs());
}
if (userInput) {
userInput.addEventListener("keydown", (e) => {
if (e.key === "Enter") loadAuditLogs();
});
}
if (actionSelect) {
actionSelect.addEventListener("change", () => loadAuditLogs());
}
}
// ── Section nav (scrollspy) ───────────────────────────────────────────
/**
* Wire the sticky section nav: smooth-scroll on click + highlight the section
* currently in view (IntersectionObserver). Falls back to default anchor jump
* if the observer is unavailable.
*/
function _wireSectionNav() {
const links = Array.from(document.querySelectorAll("#admin-subnav a"));
if (!links.length) return;
const targets = links
.map((a) => document.getElementById((a.getAttribute("href") || "#").slice(1)))
.filter(Boolean);
// Smooth scroll on click
links.forEach((a) => {
a.addEventListener("click", (e) => {
const href = a.getAttribute("href");
if (!href || !href.startsWith("#")) return;
const t = document.getElementById(href.slice(1));
if (!t) return;
e.preventDefault();
t.scrollIntoView({ behavior: "smooth", block: "start" });
});
});
// Scrollspy: mark the section currently at the top of the viewport
let active = null;
const setActive = (id) => {
if (active === id) return;
active = id;
links.forEach((l) => l.classList.toggle("active", l.getAttribute("href") === "#" + id));
};
if ("IntersectionObserver" in window) {
const io = new IntersectionObserver(
(entries) => {
entries.forEach((en) => {
if (en.isIntersecting) setActive(en.target.id);
});
},
{ rootMargin: "-64px 0px -70% 0px", threshold: 0 }
);
targets.forEach((t) => io.observe(t));
} else {
// Fallback: highlight the first section only (no scroll observation)
links[0] && links[0].classList.add("active");
}
}
// ── Auth gate ────────────────────────────────────────────────────────────
/**
* Verify the current session and admin role.
* Uses /api/auth/me (which returns the current user) and falls back to
* the cached user in sessionStorage if the request fails.
* Redirects to / if not authenticated or not admin.
* Returns the user object on success, null otherwise.
*/
async function _gateAdmin() {
const container = document.getElementById("admin-main");
const forbidden = document.getElementById("admin-forbidden");
// First check whether auth is even enabled (public endpoint).
let authEnabled = false;
try {
const statusRes = await fetch("/api/auth/status", { credentials: "include" });
if (statusRes.ok) {
const status = await statusRes.json();
authEnabled = !!status.auth_enabled;
}
} catch { /* network error — fall through */ }
if (!authEnabled) {
// Server is wide open, treat current visitor as allowed.
return { username: "anonymous", role: "admin" };
}
// Auth is enabled — try to load the current user.
try {
const meRes = await fetch("/api/auth/me", {
credentials: "include",
headers: getAuthHeaders(),
});
if (meRes.status === 401 || meRes.status === 403) {
window.location.href = "/";
return null;
}
if (!meRes.ok) throw new Error("HTTP " + meRes.status);
const user = await meRes.json();
// Cache for later fallback
try {
sessionStorage.setItem("obsigate_user", JSON.stringify(user));
} catch { /* */ }
if (user.role !== "admin") {
if (container) container.style.display = "none";
if (forbidden) forbidden.style.display = "";
return null;
}
return user;
} catch (err) {
// Network error — try to use cached sessionStorage user as fallback
try {
const cached = sessionStorage.getItem("obsigate_user");
if (cached) {
const u = JSON.parse(cached);
if (u.role === "admin") return u;
}
} catch { /* */ }
window.location.href = "/";
return null;
}
}
// ── Init ─────────────────────────────────────────────────────────────────
/**
* Page entry point. Called on DOMContentLoaded by admin.html.
* Verifies auth, populates the action select, wires events, kicks off loads.
*/
export async function init() {
const user = await _gateAdmin();
if (!user) return;
// Show role badge
const badge = document.getElementById("admin-role-badge");
if (badge) {
badge.textContent = user.role || "user";
badge.dataset.role = user.role || "user";
}
_populateAuditActions();
_wireAuditFilters();
_wireUserMgmt();
_wireSectionNav();
// Initial loads (in parallel — none depends on another)
loadStatsOnce();
loadAuditLogs();
loadBackupStats();
// Then start the live SSE stream for stats
connectSSE();
}