Audit-Log + Brute-Force-Schutz + unlimited_budget + User-Delete-Fix
- Schema-Migration: ON DELETE SET NULL fuer incidents.created_by, magic_links.user_id, network_analyses.created_by (behebt 500er beim User-Loeschen). Neue Spalte licenses.unlimited_budget. Neue Tabellen portal_audit_log, portal_login_attempts. - Audit-Log: alle CREATE/UPDATE/DELETE auf Org/User/Lizenz/Quelle + Login-Events werden mit before/after-Diff in portal_audit_log geschrieben. - Brute-Force-Schutz: 5 Fehlversuche pro IP+Username/15min -> 429 mit Retry-After. - Token-Budget: expliziter Schalter unlimited_budget pro Lizenz. UI zeigt ehrlich >100%-Verbrauch (kein Math.min mehr) und ungebremste Anzeige bei unlimited. - Neuer Audit-Log Tab mit Filter (Aktion/Ressource/Admin/Zeitraum) und Pagination.
Dieser Commit ist enthalten in:
@@ -777,3 +777,16 @@ tr:hover td {
|
||||
.token-budget-bar.warning { background: #e67e22; }
|
||||
.token-budget-bar.critical { background: #e74c3c; }
|
||||
@media (max-width: 768px) { .token-stats-row { grid-template-columns: repeat(2, 1fr); } }
|
||||
|
||||
/* === Audit-Log === */
|
||||
.audit-row { cursor: pointer; }
|
||||
.audit-row:hover { background: var(--bg-hover, rgba(255,255,255,0.03)); }
|
||||
.audit-detail-row td { background: var(--bg-tertiary, #0f172a); padding: 12px 16px; }
|
||||
.audit-diff { width: 100%; border-collapse: collapse; font-size: 12px; }
|
||||
.audit-diff th, .audit-diff td { padding: 4px 8px; border-bottom: 1px solid var(--border); text-align: left; vertical-align: top; }
|
||||
.audit-diff th { font-weight: 600; color: var(--text-secondary); }
|
||||
.audit-diff .diff-key { font-weight: 600; color: var(--text-secondary); width: 180px; word-break: break-word; }
|
||||
.audit-diff .diff-old { color: #e74c3c; word-break: break-word; }
|
||||
.audit-diff .diff-new { color: #2ecc71; word-break: break-word; }
|
||||
.token-budget-bar.over-limit { background: repeating-linear-gradient(45deg, #c0392b, #c0392b 6px, #962d22 6px, #962d22 12px); }
|
||||
input[type="date"].filter-select { padding: 6px 10px; }
|
||||
|
||||
@@ -31,6 +31,7 @@
|
||||
<button class="nav-tab" data-section="orgs">Organisationen</button>
|
||||
<button class="nav-tab" data-section="licenses">Lizenzen</button>
|
||||
<button class="nav-tab" data-section="sources">Quellen</button>
|
||||
<button class="nav-tab" data-section="audit">Audit-Log</button>
|
||||
</nav>
|
||||
|
||||
<!-- Dashboard Section -->
|
||||
@@ -224,6 +225,12 @@
|
||||
<div class="card" style="margin-top:12px;">
|
||||
<div class="card-body">
|
||||
<form id="tokenBudgetForm" style="display:grid; grid-template-columns:1fr 1fr; gap:12px;">
|
||||
<div style="grid-column:1/-1; padding:8px 12px; background:var(--bg-tertiary,#0f172a); border-radius:6px; border:1px solid var(--border);">
|
||||
<label style="display:flex; align-items:center; gap:8px; cursor:pointer; margin:0;">
|
||||
<input type="checkbox" id="editUnlimitedBudget" onchange="onUnlimitedToggle()">
|
||||
<span><strong>Unbegrenztes Budget</strong> — Verbrauch wird trotzdem getrackt, aber kein Limit/Hard-Stop</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="editCreditsTotal">Credits-Kontingent</label>
|
||||
<input type="number" id="editCreditsTotal" placeholder="z.B. 600000">
|
||||
@@ -392,6 +399,48 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Audit-Log Section -->
|
||||
<div class="section" id="sec-audit">
|
||||
<div class="action-bar" style="flex-wrap:wrap;gap:8px;">
|
||||
<div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;">
|
||||
<select class="filter-select" id="auditFilterAction">
|
||||
<option value="">Alle Aktionen</option>
|
||||
</select>
|
||||
<select class="filter-select" id="auditFilterResource">
|
||||
<option value="">Alle Ressourcen</option>
|
||||
</select>
|
||||
<select class="filter-select" id="auditFilterAdmin">
|
||||
<option value="">Alle Admins</option>
|
||||
</select>
|
||||
<input type="date" class="filter-select" id="auditFilterFrom" title="Von (Datum)">
|
||||
<input type="date" class="filter-select" id="auditFilterTo" title="Bis (Datum)">
|
||||
<button class="btn btn-secondary btn-small" id="auditFilterReset">Filter zuruecksetzen</button>
|
||||
<span class="text-secondary" id="auditCount"></span>
|
||||
</div>
|
||||
<div style="display:flex;align-items:center;gap:8px;">
|
||||
<button class="btn btn-secondary btn-small" id="auditPrevBtn" disabled>← Zurueck</button>
|
||||
<button class="btn btn-secondary btn-small" id="auditNextBtn" disabled>Weiter →</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width:170px;">Zeitpunkt</th>
|
||||
<th style="width:120px;">Admin</th>
|
||||
<th style="width:140px;">IP</th>
|
||||
<th style="width:140px;">Aktion</th>
|
||||
<th>Ressource</th>
|
||||
<th style="width:50px;"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="auditTable"><tr><td colspan="6" class="text-muted">Lade...</td></tr></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- Modal: New Organization -->
|
||||
@@ -478,6 +527,12 @@
|
||||
<div class="text-muted mt-8" style="font-size: 12px;">Trial: Standard 14 Tage, Jahreslizenz: Standard 365 Tage</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group" style="background:var(--bg-tertiary,#0f172a); padding:8px 12px; border-radius:6px; border:1px solid var(--border);">
|
||||
<label style="display:flex;align-items:center;gap:8px;cursor:pointer;margin:0;">
|
||||
<input type="checkbox" id="newLicUnlimited" onchange="onNewLicUnlimitedToggle()">
|
||||
<span><strong>Unbegrenztes Budget</strong> — getrackt, aber kein Hard-Stop</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="newLicCreditsTotal">Credits-Kontingent</label>
|
||||
<input type="number" id="newLicCreditsTotal" placeholder="z.B. 600000">
|
||||
@@ -626,5 +681,6 @@
|
||||
<script src="/static/js/app.js"></script>
|
||||
<script src="/static/js/sources.js"></script>
|
||||
<script src="/static/js/source-health.js"></script>
|
||||
<script src="/static/js/audit.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -52,6 +52,11 @@
|
||||
}),
|
||||
});
|
||||
|
||||
if (res.status === 429) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
throw new Error(data.detail || 'Zu viele Fehlversuche. Bitte 15 Minuten warten.');
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
const data = await res.json();
|
||||
throw new Error(data.detail || 'Anmeldung fehlgeschlagen');
|
||||
|
||||
@@ -64,6 +64,7 @@ function setupNavTabs() {
|
||||
document.getElementById(`sec-${section}`).classList.add("active");
|
||||
|
||||
if (section === "licenses") loadExpiringLicenses();
|
||||
if (section === "audit" && typeof loadAudit === "function") loadAudit();
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -489,12 +490,16 @@ function setupForms() {
|
||||
if (licType !== "permanent") {
|
||||
body.duration_days = parseInt(document.getElementById("newLicDuration").value);
|
||||
}
|
||||
const creditsTotal = document.getElementById('newLicCreditsTotal');
|
||||
const costPerCredit = document.getElementById('newLicCostPerCredit');
|
||||
const budgetUsd = document.getElementById('newLicBudgetUsd');
|
||||
if (creditsTotal && creditsTotal.value) body.credits_total = parseInt(creditsTotal.value);
|
||||
if (costPerCredit && costPerCredit.value) body.cost_per_credit = parseFloat(costPerCredit.value);
|
||||
if (budgetUsd && budgetUsd.value) body.token_budget_usd = parseFloat(budgetUsd.value);
|
||||
const unlimitedEl = document.getElementById('newLicUnlimited');
|
||||
body.unlimited_budget = !!(unlimitedEl && unlimitedEl.checked);
|
||||
if (!body.unlimited_budget) {
|
||||
const creditsTotal = document.getElementById('newLicCreditsTotal');
|
||||
const costPerCredit = document.getElementById('newLicCostPerCredit');
|
||||
const budgetUsd = document.getElementById('newLicBudgetUsd');
|
||||
if (creditsTotal && creditsTotal.value) body.credits_total = parseInt(creditsTotal.value);
|
||||
if (costPerCredit && costPerCredit.value) body.cost_per_credit = parseFloat(costPerCredit.value);
|
||||
if (budgetUsd && budgetUsd.value) body.token_budget_usd = parseFloat(budgetUsd.value);
|
||||
}
|
||||
try {
|
||||
await API.post("/api/licenses", body);
|
||||
closeModal("modalNewLicense");
|
||||
@@ -599,11 +604,17 @@ async function loadOrgTokenUsage(orgId) {
|
||||
|
||||
const budget = current.budget || {};
|
||||
const usage = current.usage || {};
|
||||
const unlimited = !!budget.unlimited_budget;
|
||||
|
||||
const el = (id, val) => { const e = document.getElementById(id); if (e) e.textContent = val; };
|
||||
el('tokenCreditsUsed', budget.credits_used != null ? Math.round(budget.credits_used).toLocaleString('de-DE') : '-');
|
||||
el('tokenCreditsRemaining', budget.credits_remaining != null ? budget.credits_remaining.toLocaleString('de-DE') : '-');
|
||||
el('tokenBudgetUsd', budget.token_budget_usd != null ? '$' + Number(budget.token_budget_usd).toFixed(2) : '-');
|
||||
if (unlimited) {
|
||||
el('tokenCreditsRemaining', '∞ Unbegrenzt');
|
||||
el('tokenBudgetUsd', '—');
|
||||
} else {
|
||||
el('tokenCreditsRemaining', budget.credits_remaining != null ? budget.credits_remaining.toLocaleString('de-DE') : '-');
|
||||
el('tokenBudgetUsd', budget.token_budget_usd != null ? '$' + Number(budget.token_budget_usd).toFixed(2) : '-');
|
||||
}
|
||||
el('tokenCostUsd', usage.total_cost_usd != null ? '$' + Number(usage.total_cost_usd).toFixed(2) : '-');
|
||||
|
||||
// Source-Split anzeigen
|
||||
@@ -616,14 +627,29 @@ async function loadOrgTokenUsage(orgId) {
|
||||
|
||||
const bar = document.getElementById('tokenBudgetBar');
|
||||
const percentEl = document.getElementById('tokenBudgetPercent');
|
||||
const percent = budget.credits_percent_used || 0;
|
||||
if (bar) {
|
||||
bar.style.width = Math.min(100, percent) + '%';
|
||||
bar.classList.remove('warning', 'critical');
|
||||
if (percent > 80) bar.classList.add('critical');
|
||||
else if (percent > 50) bar.classList.add('warning');
|
||||
const barWrap = bar ? bar.parentElement : null;
|
||||
if (unlimited) {
|
||||
if (bar) bar.style.width = '0%';
|
||||
if (percentEl) percentEl.textContent = 'Unbegrenzt';
|
||||
if (barWrap) barWrap.style.opacity = '0.4';
|
||||
} else {
|
||||
const percent = budget.credits_percent_used || 0;
|
||||
if (bar) {
|
||||
bar.style.width = Math.min(100, percent) + '%';
|
||||
bar.classList.remove('warning', 'critical', 'over-limit');
|
||||
if (percent > 100) bar.classList.add('over-limit');
|
||||
else if (percent > 80) bar.classList.add('critical');
|
||||
else if (percent > 50) bar.classList.add('warning');
|
||||
}
|
||||
if (percentEl) {
|
||||
percentEl.textContent = percent > 100
|
||||
? ('UEBER LIMIT (' + percent.toFixed(1) + '%)')
|
||||
: (percent.toFixed(1) + '%');
|
||||
percentEl.style.color = percent > 100 ? 'var(--danger-text, #991b1b)' : '';
|
||||
percentEl.style.fontWeight = percent > 100 ? '700' : '';
|
||||
}
|
||||
if (barWrap) barWrap.style.opacity = '1';
|
||||
}
|
||||
if (percentEl) percentEl.textContent = percent.toFixed(1) + '%';
|
||||
|
||||
fillBudgetForm(budget);
|
||||
|
||||
@@ -655,6 +681,37 @@ function fillBudgetForm(budget) {
|
||||
el('editCostPerCredit', budget.cost_per_credit);
|
||||
el('editBudgetUsd', budget.token_budget_usd);
|
||||
el('editCreditsUsed', budget.credits_used ? Math.round(budget.credits_used) : 0);
|
||||
const cb = document.getElementById('editUnlimitedBudget');
|
||||
if (cb) {
|
||||
cb.checked = !!budget.unlimited_budget;
|
||||
onUnlimitedToggle();
|
||||
}
|
||||
}
|
||||
|
||||
// Unlimited-Toggle: Felder ausgrauen wenn aktiv
|
||||
function onUnlimitedToggle() {
|
||||
const cb = document.getElementById('editUnlimitedBudget');
|
||||
const isUnlimited = cb && cb.checked;
|
||||
['editCreditsTotal','editCostPerCredit','editBudgetUsd'].forEach(function(id) {
|
||||
const e = document.getElementById(id);
|
||||
if (e) {
|
||||
e.disabled = isUnlimited;
|
||||
e.style.opacity = isUnlimited ? '0.4' : '1';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Unlimited-Toggle im Lizenz-Modal
|
||||
function onNewLicUnlimitedToggle() {
|
||||
const cb = document.getElementById('newLicUnlimited');
|
||||
const isUnlimited = cb && cb.checked;
|
||||
['newLicCreditsTotal','newLicCostPerCredit','newLicBudgetUsd'].forEach(function(id) {
|
||||
const e = document.getElementById(id);
|
||||
if (e) {
|
||||
e.disabled = isUnlimited;
|
||||
e.style.opacity = isUnlimited ? '0.4' : '1';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function loadDashboardTokenStats() {
|
||||
@@ -701,19 +758,34 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
}
|
||||
|
||||
var body = {};
|
||||
var unlimitedCb = document.getElementById('editUnlimitedBudget');
|
||||
var isUnlimited = !!(unlimitedCb && unlimitedCb.checked);
|
||||
body.unlimited_budget = isUnlimited;
|
||||
|
||||
var creditsTotal = document.getElementById('editCreditsTotal');
|
||||
var costPerCredit = document.getElementById('editCostPerCredit');
|
||||
var budgetUsd = document.getElementById('editBudgetUsd');
|
||||
var creditsUsed = document.getElementById('editCreditsUsed');
|
||||
|
||||
if (creditsTotal && creditsTotal.value) body.credits_total = parseInt(creditsTotal.value);
|
||||
if (costPerCredit && costPerCredit.value) body.cost_per_credit = parseFloat(costPerCredit.value);
|
||||
if (budgetUsd && budgetUsd.value) body.token_budget_usd = parseFloat(budgetUsd.value);
|
||||
if (!isUnlimited) {
|
||||
if (creditsTotal && creditsTotal.value) body.credits_total = parseInt(creditsTotal.value);
|
||||
if (costPerCredit && costPerCredit.value) body.cost_per_credit = parseFloat(costPerCredit.value);
|
||||
if (budgetUsd && budgetUsd.value) body.token_budget_usd = parseFloat(budgetUsd.value);
|
||||
}
|
||||
// credits_used immer mitsenden (auch im Unlimited-Modus, fuer Korrekturen)
|
||||
if (creditsUsed && creditsUsed.value !== '') body.credits_used = parseFloat(creditsUsed.value);
|
||||
|
||||
await API.put('/api/token-usage/budget/' + activeLic.id, body);
|
||||
if (msgEl) msgEl.textContent = 'Gespeichert!';
|
||||
setTimeout(function() { if (msgEl) msgEl.textContent = ''; }, 3000);
|
||||
var result = await API.put('/api/token-usage/budget/' + activeLic.id, body);
|
||||
if (msgEl) {
|
||||
if (result && result.warning) {
|
||||
msgEl.textContent = result.warning;
|
||||
msgEl.style.color = 'var(--danger-text, #991b1b)';
|
||||
} else {
|
||||
msgEl.textContent = 'Gespeichert!';
|
||||
msgEl.style.color = '';
|
||||
}
|
||||
}
|
||||
setTimeout(function() { if (msgEl) { msgEl.textContent = ''; msgEl.style.color = ''; } }, 5000);
|
||||
|
||||
// Daten neu laden
|
||||
loadOrgTokenUsage(currentOrgId);
|
||||
|
||||
229
src/static/js/audit.js
Normale Datei
229
src/static/js/audit.js
Normale Datei
@@ -0,0 +1,229 @@
|
||||
/* Audit-Log Tab */
|
||||
"use strict";
|
||||
|
||||
let auditCache = { items: [], total: 0, offset: 0, limit: 200 };
|
||||
let auditDistinct = { actions: [], resource_types: [], admins: [] };
|
||||
let expandedRows = new Set();
|
||||
|
||||
const ACTION_LABELS = {
|
||||
create: "Erstellt",
|
||||
update: "Geändert",
|
||||
delete: "Gelöscht",
|
||||
login_success: "Login erfolgreich",
|
||||
login_failed: "Login fehlgeschlagen",
|
||||
login_blocked: "Login blockiert",
|
||||
};
|
||||
|
||||
const RESOURCE_LABELS = {
|
||||
organization: "Organisation",
|
||||
user: "Nutzer",
|
||||
license: "Lizenz",
|
||||
source: "Quelle",
|
||||
admin: "Admin",
|
||||
auth: "Auth",
|
||||
};
|
||||
|
||||
const ACTION_BADGE_CLASS = {
|
||||
create: "active",
|
||||
update: "trial",
|
||||
delete: "inactive",
|
||||
login_success: "active",
|
||||
login_failed: "inactive",
|
||||
login_blocked: "inactive",
|
||||
};
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
document.querySelectorAll('.nav-tab[data-section="audit"]').forEach((tab) => {
|
||||
tab.addEventListener("click", () => loadAudit());
|
||||
});
|
||||
|
||||
// Filter-Inputs verdrahten
|
||||
["auditFilterAction", "auditFilterResource", "auditFilterAdmin",
|
||||
"auditFilterFrom", "auditFilterTo"].forEach((id) => {
|
||||
const el = document.getElementById(id);
|
||||
if (el) el.addEventListener("change", () => { auditCache.offset = 0; loadAudit(); });
|
||||
});
|
||||
|
||||
const reset = document.getElementById("auditFilterReset");
|
||||
if (reset) reset.addEventListener("click", () => {
|
||||
["auditFilterAction", "auditFilterResource", "auditFilterAdmin",
|
||||
"auditFilterFrom", "auditFilterTo"].forEach((id) => {
|
||||
const el = document.getElementById(id);
|
||||
if (el) el.value = "";
|
||||
});
|
||||
auditCache.offset = 0;
|
||||
loadAudit();
|
||||
});
|
||||
|
||||
const prev = document.getElementById("auditPrevBtn");
|
||||
const next = document.getElementById("auditNextBtn");
|
||||
if (prev) prev.addEventListener("click", () => {
|
||||
auditCache.offset = Math.max(0, auditCache.offset - auditCache.limit);
|
||||
loadAudit();
|
||||
});
|
||||
if (next) next.addEventListener("click", () => {
|
||||
if (auditCache.offset + auditCache.limit < auditCache.total) {
|
||||
auditCache.offset += auditCache.limit;
|
||||
loadAudit();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
async function loadAuditDistinct() {
|
||||
try {
|
||||
auditDistinct = await API.get("/api/audit-log/distinct");
|
||||
|
||||
const actSel = document.getElementById("auditFilterAction");
|
||||
if (actSel && actSel.options.length <= 1) {
|
||||
auditDistinct.actions.forEach((a) => {
|
||||
const opt = document.createElement("option");
|
||||
opt.value = a;
|
||||
opt.textContent = ACTION_LABELS[a] || a;
|
||||
actSel.appendChild(opt);
|
||||
});
|
||||
}
|
||||
|
||||
const resSel = document.getElementById("auditFilterResource");
|
||||
if (resSel && resSel.options.length <= 1) {
|
||||
auditDistinct.resource_types.forEach((r) => {
|
||||
const opt = document.createElement("option");
|
||||
opt.value = r;
|
||||
opt.textContent = RESOURCE_LABELS[r] || r;
|
||||
resSel.appendChild(opt);
|
||||
});
|
||||
}
|
||||
|
||||
const admSel = document.getElementById("auditFilterAdmin");
|
||||
if (admSel && admSel.options.length <= 1) {
|
||||
auditDistinct.admins.forEach((a) => {
|
||||
const opt = document.createElement("option");
|
||||
opt.value = a.id;
|
||||
opt.textContent = a.username;
|
||||
admSel.appendChild(opt);
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Audit-Filter laden fehlgeschlagen:", err);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadAudit() {
|
||||
await loadAuditDistinct();
|
||||
const params = new URLSearchParams();
|
||||
const action = document.getElementById("auditFilterAction")?.value;
|
||||
const resource = document.getElementById("auditFilterResource")?.value;
|
||||
const adminId = document.getElementById("auditFilterAdmin")?.value;
|
||||
const from = document.getElementById("auditFilterFrom")?.value;
|
||||
const to = document.getElementById("auditFilterTo")?.value;
|
||||
|
||||
if (action) params.append("action", action);
|
||||
if (resource) params.append("resource_type", resource);
|
||||
if (adminId) params.append("admin_id", adminId);
|
||||
if (from) params.append("from_ts", from);
|
||||
if (to) params.append("to_ts", to);
|
||||
params.append("limit", auditCache.limit);
|
||||
params.append("offset", auditCache.offset);
|
||||
|
||||
try {
|
||||
const data = await API.get("/api/audit-log?" + params.toString());
|
||||
auditCache.items = data.items;
|
||||
auditCache.total = data.total;
|
||||
auditCache.offset = data.offset;
|
||||
renderAudit();
|
||||
} catch (err) {
|
||||
console.error("Audit-Log laden fehlgeschlagen:", err);
|
||||
}
|
||||
}
|
||||
|
||||
function formatAuditTs(ts) {
|
||||
if (!ts) return "-";
|
||||
try {
|
||||
const d = new Date(ts.replace(" ", "T") + "Z");
|
||||
return d.toLocaleString("de-DE", {
|
||||
day: "2-digit", month: "2-digit", year: "numeric",
|
||||
hour: "2-digit", minute: "2-digit", second: "2-digit",
|
||||
});
|
||||
} catch { return ts; }
|
||||
}
|
||||
|
||||
function renderAuditDiff(before, after) {
|
||||
if (before === null && after === null) return "—";
|
||||
if (before === null && after !== null) {
|
||||
// CREATE: voller Datensatz
|
||||
const keys = Object.keys(after).filter(k => k !== "password_hash");
|
||||
return '<table class="audit-diff"><tbody>' +
|
||||
keys.map(k => `<tr><td class="diff-key">${esc(k)}</td><td>${esc(JSON.stringify(after[k]))}</td></tr>`).join("") +
|
||||
'</tbody></table>';
|
||||
}
|
||||
if (before !== null && after === null) {
|
||||
const keys = Object.keys(before).filter(k => k !== "password_hash");
|
||||
return '<table class="audit-diff"><tbody>' +
|
||||
keys.map(k => `<tr><td class="diff-key">${esc(k)}</td><td>${esc(JSON.stringify(before[k]))}</td></tr>`).join("") +
|
||||
'</tbody></table>';
|
||||
}
|
||||
// UPDATE: Diff zeigen
|
||||
const keys = new Set([...Object.keys(before || {}), ...Object.keys(after || {})]);
|
||||
return '<table class="audit-diff"><thead><tr><th>Feld</th><th>vorher</th><th>nachher</th></tr></thead><tbody>' +
|
||||
[...keys].map(k => {
|
||||
const bv = (before || {})[k];
|
||||
const av = (after || {})[k];
|
||||
return `<tr><td class="diff-key">${esc(k)}</td><td class="diff-old">${esc(JSON.stringify(bv))}</td><td class="diff-new">${esc(JSON.stringify(av))}</td></tr>`;
|
||||
}).join("") +
|
||||
'</tbody></table>';
|
||||
}
|
||||
|
||||
function renderAudit() {
|
||||
const tbody = document.getElementById("auditTable");
|
||||
const countEl = document.getElementById("auditCount");
|
||||
if (!tbody) return;
|
||||
|
||||
if (auditCache.items.length === 0) {
|
||||
tbody.innerHTML = '<tr><td colspan="6" class="text-muted">Keine Eintraege</td></tr>';
|
||||
if (countEl) countEl.textContent = "0 Eintraege";
|
||||
updateAuditPaginator();
|
||||
return;
|
||||
}
|
||||
|
||||
let html = "";
|
||||
auditCache.items.forEach((it) => {
|
||||
const actLabel = ACTION_LABELS[it.action] || it.action;
|
||||
const resLabel = it.resource_type ? (RESOURCE_LABELS[it.resource_type] || it.resource_type) : "—";
|
||||
const resId = it.resource_id ? "#" + it.resource_id : "";
|
||||
const badgeCls = ACTION_BADGE_CLASS[it.action] || "trial";
|
||||
const expanded = expandedRows.has(it.id);
|
||||
const arrow = expanded ? "▼" : "▶";
|
||||
|
||||
html += `<tr class="audit-row" onclick="toggleAuditRow(${it.id})">
|
||||
<td>${formatAuditTs(it.ts)}</td>
|
||||
<td>${esc(it.admin_username || "—")}</td>
|
||||
<td>${esc(it.ip || "—")}</td>
|
||||
<td><span class="badge badge-${badgeCls}">${esc(actLabel)}</span></td>
|
||||
<td>${esc(resLabel)} ${esc(resId)}</td>
|
||||
<td style="text-align:right;color:var(--text-secondary)">${arrow}</td>
|
||||
</tr>`;
|
||||
if (expanded) {
|
||||
html += `<tr class="audit-detail-row" id="audit-detail-${it.id}"><td colspan="6">${renderAuditDiff(it.before, it.after)}</td></tr>`;
|
||||
}
|
||||
});
|
||||
tbody.innerHTML = html;
|
||||
|
||||
if (countEl) {
|
||||
const start = auditCache.offset + 1;
|
||||
const end = Math.min(auditCache.offset + auditCache.items.length, auditCache.total);
|
||||
countEl.textContent = `${start}-${end} von ${auditCache.total}`;
|
||||
}
|
||||
updateAuditPaginator();
|
||||
}
|
||||
|
||||
function toggleAuditRow(id) {
|
||||
if (expandedRows.has(id)) expandedRows.delete(id);
|
||||
else expandedRows.add(id);
|
||||
renderAudit();
|
||||
}
|
||||
|
||||
function updateAuditPaginator() {
|
||||
const prev = document.getElementById("auditPrevBtn");
|
||||
const next = document.getElementById("auditNextBtn");
|
||||
if (prev) prev.disabled = auditCache.offset <= 0;
|
||||
if (next) next.disabled = auditCache.offset + auditCache.limit >= auditCache.total;
|
||||
}
|
||||
In neuem Issue referenzieren
Einen Benutzer sperren