diff --git a/src/middleware/license_check.py b/src/middleware/license_check.py index f5ad914..553e75e 100644 --- a/src/middleware/license_check.py +++ b/src/middleware/license_check.py @@ -40,12 +40,25 @@ async def require_writable_license( ) -> dict: """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", {}) 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( status_code=status.HTTP_403_FORBIDDEN, - detail="Lizenz abgelaufen oder widerrufen. Nur Lesezugriff moeglich.", + detail=detail, + headers={"X-License-Status": reason}, ) return current_user diff --git a/src/models.py b/src/models.py index 5a09b2b..6c1e547 100644 --- a/src/models.py +++ b/src/models.py @@ -37,6 +37,8 @@ class UserMeResponse(BaseModel): license_status: str = "unknown" license_type: str = "" read_only: bool = False + read_only_reason: Optional[str] = None + unlimited_budget: bool = False credits_total: Optional[int] = None credits_remaining: Optional[int] = None credits_percent_used: Optional[float] = None diff --git a/src/routers/auth.py b/src/routers/auth.py index 21c5cc3..7f68cf8 100644 --- a/src/routers/auth.py +++ b/src/routers/auth.py @@ -187,10 +187,11 @@ async def get_me( from services.license_service import check_license license_info = await check_license(db, current_user["tenant_id"]) - # Credits-Daten laden + # Credits-Daten laden (echte Prozente, nicht gekappt) credits_total = None credits_remaining = None credits_percent_used = None + unlimited_budget = bool(license_info.get("unlimited_budget", False)) if current_user.get("tenant_id"): 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", @@ -200,7 +201,7 @@ async def get_me( credits_total = lic_row["credits_total"] credits_used = lic_row["credits_used"] or 0 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( id=current_user["id"], @@ -216,6 +217,8 @@ async def get_me( license_status=license_info.get("status", "unknown"), license_type=license_info.get("license_type", ""), 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), ) diff --git a/src/services/license_service.py b/src/services/license_service.py index 61eaa11..52f2a75 100644 --- a/src/services/license_service.py +++ b/src/services/license_service.py @@ -11,7 +11,8 @@ async def check_license(db: aiosqlite.Connection, organization_id: int) -> dict: """Prueft den Lizenzstatus einer Organisation. 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 cursor = await db.execute( @@ -20,10 +21,14 @@ async def check_license(db: aiosqlite.Connection, organization_id: int) -> dict: ) org = await cursor.fetchone() 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"]: - 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 cursor = await db.execute( @@ -35,7 +40,15 @@ async def check_license(db: aiosqlite.Connection, organization_id: int) -> dict: license_row = await cursor.fetchone() 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 now = datetime.now(TIMEZONE) @@ -52,11 +65,21 @@ async def check_license(db: aiosqlite.Connection, organization_id: int) -> dict: "status": "expired", "license_type": license_row["license_type"], "read_only": True, + "read_only_reason": "expired", "message": "Lizenz abgelaufen", + "unlimited_budget": unlimited_budget, + "credits_total": credits_total, + "credits_used": credits_used, } except (ValueError, TypeError): 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 cursor = await db.execute( "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"] + 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 { "valid": True, "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"], "current_users": current_users, "read_only": False, + "read_only_reason": None, "message": "Lizenz aktiv", + "unlimited_budget": unlimited_budget, + "credits_total": credits_total, + "credits_used": credits_used, } diff --git a/src/static/js/api.js b/src/static/js/api.js index 410960b..310476d 100644 --- a/src/static/js/api.js +++ b/src/static/js/api.js @@ -67,6 +67,29 @@ const API = { } else if (typeof detail === 'object' && detail !== null) { 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); } diff --git a/src/static/js/app.js b/src/static/js/app.js index aa88a6d..13f2d64 100644 --- a/src/static/js/app.js +++ b/src/static/js/app.js @@ -450,6 +450,7 @@ const App = { try { const user = await API.getMe(); + this.user = user; this._currentUsername = 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'); - if (warningEl && user.read_only) { - warningEl.textContent = 'Lizenz abgelaufen – nur Lesezugriff'; - warningEl.classList.add('visible'); + if (warningEl) { + if (user.read_only) { + 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) --- @@ -2130,8 +2147,19 @@ async handleRefresh() { _updateRefreshButton(disabled) { const btn = document.getElementById('refresh-btn'); 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.textContent = disabled ? 'Läuft...' : 'Aktualisieren'; + btn.title = ''; }, async handleDelete() {