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.
Dieser Commit ist enthalten in:
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
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');
|
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() {
|
||||||
|
|||||||
In neuem Issue referenzieren
Einen Benutzer sperren