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:
@@ -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);
|
||||
|
||||
In neuem Issue referenzieren
Einen Benutzer sperren