Commits vergleichen

..

2 Commits

Autor SHA1 Nachricht Datum
Claude Code
74d76d2e50 Promote develop → main (2026-05-02 20:30 UTC) 2026-05-02 20:25:29 +00:00
Claude Code
ee83f38edf Token-Budget Hard-Stop + Banner bei aufgebrauchtem Budget
- check_license() liefert jetzt unlimited_budget, credits_total, credits_used,
  read_only_reason. Bei nicht-unlimited UND credits_used >= credits_total wird
  status=budget_exceeded, read_only=True gesetzt.
- require_writable_license blockiert mit 403 + X-License-Status-Header je nach Reason.
- /api/auth/me liefert read_only_reason und unlimited_budget; credits_percent_used
  wird nicht mehr auf 100 gekappt (echte Prozente).
- Frontend: Banner-Text dynamisch je nach reason (budget_exceeded/expired/...).
  Refresh-Button bei read_only deaktiviert + Tooltip. Globaler 403-Handler in
  api.js: bei X-License-Status -> Banner + Toast aktualisieren.
2026-05-02 20:16:25 +00:00
6 geänderte Dateien mit 123 neuen und 12 gelöschten Zeilen

Datei anzeigen

@@ -40,12 +40,25 @@ async def require_writable_license(
) -> dict: ) -> dict:
"""Dependency die sicherstellt, dass die Lizenz Schreibzugriff erlaubt. """Dependency die sicherstellt, dass die Lizenz Schreibzugriff erlaubt.
Blockiert neue Lagen/Refreshes bei abgelaufener Lizenz (Nur-Lesen-Modus). Blockiert neue Lagen/Refreshes bei abgelaufener Lizenz, deaktivierter Org
oder aufgebrauchtem Token-Budget (Hard-Stop).
""" """
lic = current_user.get("license", {}) lic = current_user.get("license", {})
if lic.get("read_only"): if lic.get("read_only"):
reason = lic.get("read_only_reason") or "expired"
if reason == "budget_exceeded":
detail = "Token-Budget aufgebraucht. Bitte Verwaltung kontaktieren."
elif reason == "expired":
detail = "Lizenz abgelaufen. Nur Lesezugriff moeglich."
elif reason == "no_license":
detail = "Keine aktive Lizenz. Bitte Verwaltung kontaktieren."
elif reason == "org_disabled":
detail = "Organisation deaktiviert. Bitte Support kontaktieren."
else:
detail = lic.get("message") or "Nur Lesezugriff moeglich."
raise HTTPException( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, status_code=status.HTTP_403_FORBIDDEN,
detail="Lizenz abgelaufen oder widerrufen. Nur Lesezugriff moeglich.", detail=detail,
headers={"X-License-Status": reason},
) )
return current_user return current_user

Datei anzeigen

@@ -37,6 +37,8 @@ class UserMeResponse(BaseModel):
license_status: str = "unknown" license_status: str = "unknown"
license_type: str = "" license_type: str = ""
read_only: bool = False read_only: bool = False
read_only_reason: Optional[str] = None
unlimited_budget: bool = False
credits_total: Optional[int] = None credits_total: Optional[int] = None
credits_remaining: Optional[int] = None credits_remaining: Optional[int] = None
credits_percent_used: Optional[float] = None credits_percent_used: Optional[float] = None

Datei anzeigen

@@ -187,10 +187,11 @@ async def get_me(
from services.license_service import check_license from services.license_service import check_license
license_info = await check_license(db, current_user["tenant_id"]) license_info = await check_license(db, current_user["tenant_id"])
# Credits-Daten laden # Credits-Daten laden (echte Prozente, nicht gekappt)
credits_total = None credits_total = None
credits_remaining = None credits_remaining = None
credits_percent_used = None credits_percent_used = None
unlimited_budget = bool(license_info.get("unlimited_budget", False))
if current_user.get("tenant_id"): if current_user.get("tenant_id"):
lic_cursor = await db.execute( lic_cursor = await db.execute(
"SELECT credits_total, credits_used, cost_per_credit FROM licenses WHERE organization_id = ? AND status = 'active' ORDER BY id DESC LIMIT 1", "SELECT credits_total, credits_used, cost_per_credit FROM licenses WHERE organization_id = ? AND status = 'active' ORDER BY id DESC LIMIT 1",
@@ -200,7 +201,7 @@ async def get_me(
credits_total = lic_row["credits_total"] credits_total = lic_row["credits_total"]
credits_used = lic_row["credits_used"] or 0 credits_used = lic_row["credits_used"] or 0
credits_remaining = max(0, int(credits_total - credits_used)) credits_remaining = max(0, int(credits_total - credits_used))
credits_percent_used = round(min(100, (credits_used / credits_total) * 100), 1) if credits_total > 0 else 0 credits_percent_used = round((credits_used / credits_total) * 100, 1) if credits_total > 0 else 0
return UserMeResponse( return UserMeResponse(
id=current_user["id"], id=current_user["id"],
@@ -216,6 +217,8 @@ async def get_me(
license_status=license_info.get("status", "unknown"), license_status=license_info.get("status", "unknown"),
license_type=license_info.get("license_type", ""), license_type=license_info.get("license_type", ""),
read_only=license_info.get("read_only", False), read_only=license_info.get("read_only", False),
read_only_reason=license_info.get("read_only_reason"),
unlimited_budget=unlimited_budget,
is_global_admin=current_user.get("is_global_admin", False), is_global_admin=current_user.get("is_global_admin", False),
) )

Datei anzeigen

@@ -11,7 +11,8 @@ async def check_license(db: aiosqlite.Connection, organization_id: int) -> dict:
"""Prueft den Lizenzstatus einer Organisation. """Prueft den Lizenzstatus einer Organisation.
Returns: Returns:
dict mit: valid, status, license_type, max_users, current_users, read_only, message dict mit: valid, status, license_type, max_users, current_users, read_only,
read_only_reason, message, unlimited_budget, credits_total, credits_used
""" """
# Organisation pruefen # Organisation pruefen
cursor = await db.execute( cursor = await db.execute(
@@ -20,10 +21,14 @@ async def check_license(db: aiosqlite.Connection, organization_id: int) -> dict:
) )
org = await cursor.fetchone() org = await cursor.fetchone()
if not org: if not org:
return {"valid": False, "status": "not_found", "read_only": True, "message": "Organisation nicht gefunden"} return {"valid": False, "status": "not_found", "read_only": True,
"read_only_reason": "not_found",
"message": "Organisation nicht gefunden"}
if not org["is_active"]: if not org["is_active"]:
return {"valid": False, "status": "org_disabled", "read_only": True, "message": "Organisation deaktiviert"} return {"valid": False, "status": "org_disabled", "read_only": True,
"read_only_reason": "org_disabled",
"message": "Organisation deaktiviert"}
# Aktive Lizenz suchen # Aktive Lizenz suchen
cursor = await db.execute( cursor = await db.execute(
@@ -35,7 +40,15 @@ async def check_license(db: aiosqlite.Connection, organization_id: int) -> dict:
license_row = await cursor.fetchone() license_row = await cursor.fetchone()
if not license_row: if not license_row:
return {"valid": False, "status": "no_license", "read_only": True, "message": "Keine aktive Lizenz"} return {"valid": False, "status": "no_license", "read_only": True,
"read_only_reason": "no_license",
"message": "Keine aktive Lizenz"}
# Felder zur weiteren Verwendung extrahieren
lic_dict = dict(license_row)
unlimited_budget = bool(lic_dict.get("unlimited_budget"))
credits_total = lic_dict.get("credits_total")
credits_used = lic_dict.get("credits_used") or 0
# Ablauf pruefen # Ablauf pruefen
now = datetime.now(TIMEZONE) now = datetime.now(TIMEZONE)
@@ -52,11 +65,21 @@ async def check_license(db: aiosqlite.Connection, organization_id: int) -> dict:
"status": "expired", "status": "expired",
"license_type": license_row["license_type"], "license_type": license_row["license_type"],
"read_only": True, "read_only": True,
"read_only_reason": "expired",
"message": "Lizenz abgelaufen", "message": "Lizenz abgelaufen",
"unlimited_budget": unlimited_budget,
"credits_total": credits_total,
"credits_used": credits_used,
} }
except (ValueError, TypeError): except (ValueError, TypeError):
pass pass
# Budget-Check (Hard-Stop bei aufgebrauchten Credits, ausser unlimited)
budget_exceeded = False
if not unlimited_budget and credits_total and credits_total > 0:
if credits_used >= credits_total:
budget_exceeded = True
# Nutzerzahl pruefen # Nutzerzahl pruefen
cursor = await db.execute( cursor = await db.execute(
"SELECT COUNT(*) as cnt FROM users WHERE organization_id = ? AND is_active = 1", "SELECT COUNT(*) as cnt FROM users WHERE organization_id = ? AND is_active = 1",
@@ -64,6 +87,21 @@ async def check_license(db: aiosqlite.Connection, organization_id: int) -> dict:
) )
current_users = (await cursor.fetchone())["cnt"] current_users = (await cursor.fetchone())["cnt"]
if budget_exceeded:
return {
"valid": True, # Lizenz ist gueltig, aber Budget aufgebraucht -> read-only
"status": "budget_exceeded",
"license_type": license_row["license_type"],
"max_users": license_row["max_users"],
"current_users": current_users,
"read_only": True,
"read_only_reason": "budget_exceeded",
"message": "Token-Budget aufgebraucht",
"unlimited_budget": False,
"credits_total": credits_total,
"credits_used": credits_used,
}
return { return {
"valid": True, "valid": True,
"status": license_row["status"], "status": license_row["status"],
@@ -71,7 +109,11 @@ async def check_license(db: aiosqlite.Connection, organization_id: int) -> dict:
"max_users": license_row["max_users"], "max_users": license_row["max_users"],
"current_users": current_users, "current_users": current_users,
"read_only": False, "read_only": False,
"read_only_reason": None,
"message": "Lizenz aktiv", "message": "Lizenz aktiv",
"unlimited_budget": unlimited_budget,
"credits_total": credits_total,
"credits_used": credits_used,
} }

Datei anzeigen

@@ -67,6 +67,29 @@ const API = {
} else if (typeof detail === 'object' && detail !== null) { } else if (typeof detail === 'object' && detail !== null) {
detail = JSON.stringify(detail); detail = JSON.stringify(detail);
} }
// Lizenz-Status aus Header auslesen (vom Backend gesetzt bei 403)
const licStatus = response.headers.get('X-License-Status');
if (response.status === 403 && licStatus && typeof App !== 'undefined') {
if (!App.user) App.user = {};
App.user.read_only = true;
App.user.read_only_reason = licStatus;
const warningEl = document.getElementById('header-license-warning');
if (warningEl) {
let text = 'Nur Lesezugriff';
if (licStatus === 'budget_exceeded') text = 'Token-Budget aufgebraucht – nur Lesezugriff. Bitte Verwaltung kontaktieren.';
else if (licStatus === 'expired') text = 'Lizenz abgelaufen – nur Lesezugriff';
else if (licStatus === 'no_license') text = 'Keine aktive Lizenz – nur Lesezugriff';
else if (licStatus === 'org_disabled') text = 'Organisation deaktiviert – nur Lesezugriff';
warningEl.textContent = text;
warningEl.classList.add('visible');
}
if (typeof App._updateRefreshButton === 'function') App._updateRefreshButton(false);
if (typeof UI !== 'undefined' && UI.showToast) {
UI.showToast(detail || 'Lizenz-Beschränkung – nur Lesezugriff', 'error');
}
}
throw new ApiError(response.status, detail); throw new ApiError(response.status, detail);
} }

Datei anzeigen

@@ -450,6 +450,7 @@ const App = {
try { try {
const user = await API.getMe(); const user = await API.getMe();
this.user = user;
this._currentUsername = user.email; this._currentUsername = user.email;
document.getElementById('header-user').textContent = user.email; document.getElementById('header-user').textContent = user.email;
@@ -515,11 +516,27 @@ const App = {
}); });
} }
// Warnung bei abgelaufener Lizenz // Warnung bei Read-Only (Lizenz abgelaufen oder Token-Budget aufgebraucht)
const warningEl = document.getElementById('header-license-warning'); const warningEl = document.getElementById('header-license-warning');
if (warningEl && user.read_only) { if (warningEl) {
warningEl.textContent = 'Lizenz abgelaufen – nur Lesezugriff'; if (user.read_only) {
warningEl.classList.add('visible'); let text = 'Nur Lesezugriff';
const reason = user.read_only_reason;
if (reason === 'budget_exceeded') {
text = 'Token-Budget aufgebraucht – nur Lesezugriff. Bitte Verwaltung kontaktieren.';
} else if (reason === 'expired') {
text = 'Lizenz abgelaufen – nur Lesezugriff';
} else if (reason === 'no_license') {
text = 'Keine aktive Lizenz – nur Lesezugriff';
} else if (reason === 'org_disabled') {
text = 'Organisation deaktiviert – nur Lesezugriff';
}
warningEl.textContent = text;
warningEl.classList.add('visible');
} else {
warningEl.textContent = '';
warningEl.classList.remove('visible');
}
} }
// --- Global Admin: Org-Switcher (herausnehmbar) --- // --- Global Admin: Org-Switcher (herausnehmbar) ---
@@ -2130,8 +2147,19 @@ async handleRefresh() {
_updateRefreshButton(disabled) { _updateRefreshButton(disabled) {
const btn = document.getElementById('refresh-btn'); const btn = document.getElementById('refresh-btn');
if (!btn) return; if (!btn) return;
// Hard-Stop: Lese-Modus (Budget aufgebraucht / Lizenz abgelaufen) -> immer disabled
if (this.user && this.user.read_only) {
btn.disabled = true;
const reason = this.user.read_only_reason;
btn.textContent = reason === 'budget_exceeded' ? 'Budget aufgebraucht' : 'Nur Lesezugriff';
btn.title = reason === 'budget_exceeded'
? 'Token-Budget aufgebraucht. Bitte Verwaltung kontaktieren.'
: 'Lizenz erlaubt keinen Schreibzugriff';
return;
}
btn.disabled = disabled; btn.disabled = disabled;
btn.textContent = disabled ? 'Läuft...' : 'Aktualisieren'; btn.textContent = disabled ? 'Läuft...' : 'Aktualisieren';
btn.title = '';
}, },
async handleDelete() { async handleDelete() {