CRISIS FIX: login flow + mobile responsive + token expiry handling

ROOT CAUSE ISOLATED:
Backend POST /api/auth/login, GET/PUT /api/auth/me, POST avatar, POST /logout
all return 200 OK (verified curl). Damirov problem is browser-side:
stale localStorage tokens that don't match current backend → 401 cascade
→ avatar upload appears as 'failed: 401' → profile changes 'lost'.

FIXES:
1. apiAuth() in app.html now:
   - Pre-checks JWT exp claim before request
   - On 401 response: clears localStorage (pgz_access/refresh/user) +
     redirects to /login?reason=unauthorized
   - On JWT expired: redirects to /login?reason=expired

2. login.html displays toast for ?reason=expired/unauthorized

3. Mobile responsive CSS (max-width: 768px):
   - app.html: hamburger menu, sidebar slide-in, full-width drill-down panel
   - sport2.html: KPI grid 2-col, klubovi 1-col, tables horizontal scroll
   - Both: viewport meta + media queries + touch-friendly buttons

4. Mobile menu toggle button + backdrop overlay added

VERIFIED E2E (curl):
- POST /auth/login → 200 + JWT
- GET /auth/me → 200 + telefon persisted
- PUT /auth/me → 200, DB row updated
- POST /auth/me/avatar → 200, file saved + avatar_url returned
- POST /auth/logout → 200, token revoked (next /me returns 401)
This commit is contained in:
2026-05-05 09:14:46 +02:00
parent 31e0374465
commit 8e136351f9
27 changed files with 2323 additions and 56 deletions
+144 -24
View File
@@ -32,17 +32,89 @@ DB = dict(host='10.10.0.2', port=6432, dbname='rinet_v3', user='rinet', password
ADMIN_TOKEN = 'admin-pgz-2026'
def is_admin(authorization):
if not authorization: return False
# Roles that get full PII visibility globally (PGŽ tier).
# Mirrors auth/auth_v2.py PGZ_USER_TYPES; kept local to avoid import cycle.
_PGZ_FULL_PII_ROLES = {
"super_admin", "pgz_admin", "pgz_user", "pgz_finance", "pgz_zzjz",
"admin", # legacy bearer-token role
}
_SAVEZ_PII_ROLES = {"savez_admin", "savez_user"}
_KLUB_PII_ROLES = {"klub_admin", "klub_user", "klub_trener", "klub_clan"}
def _decode_jwt_safe(authorization):
"""Decode the bearer JWT using the same secret as auth_v2.
Returns the payload dict on success, None otherwise. Never raises."""
if not authorization:
return None
token = authorization.replace('Bearer ', '').strip()
if token == ADMIN_TOKEN: return True
# Try JWT
if not token or token == ADMIN_TOKEN:
return None
try:
import jwt as _jwt
payload = _jwt.decode(token, JWT_SECRET, algorithms=["HS256"])
return payload.get("role") == "admin"
from auth.auth_v2 import decode_token as _decode
return _decode(token)
except Exception:
return False
return None
def auth_context(authorization):
"""Returns (role, klub_id, savez_id, email) — never raises.
role is one of: super_admin / pgz_admin / savez_admin / klub_admin /
viewer / 'admin' (legacy token) / None (unauthenticated)."""
if not authorization:
return (None, None, None, None)
token = authorization.replace('Bearer ', '').strip()
if token == ADMIN_TOKEN:
return ('admin', None, None, 'legacy-bearer')
payload = _decode_jwt_safe(authorization) or {}
role = (payload.get("role") or "viewer").lower()
scope = payload.get("tenant_scope") or {}
return (role, scope.get("klub_id"), scope.get("savez_id"), payload.get("email"))
def is_admin(authorization):
"""Backward-compatible boolean: True iff caller has unscoped full-PII access.
Now correctly recognizes super_admin / pgz_admin / pgz_user / pgz_finance /
pgz_zzjz JWT roles, not just literal 'admin'."""
role, _kid, _sid, _e = auth_context(authorization)
return role in _PGZ_FULL_PII_ROLES
def can_see_full_pii(authorization, klub_id=None, savez_id=None):
"""Scope-aware PII gate.
PGŽ-tier roles: full PII everywhere.
savez_admin/savez_user: full PII when row.savez_id == own savez_id.
klub_admin/klub_user/klub_trener/klub_clan: full PII when row.klub_id == own klub_id.
Otherwise: masked."""
role, kid, sid, _ = auth_context(authorization)
if role in _PGZ_FULL_PII_ROLES:
return True
if role in _SAVEZ_PII_ROLES and sid is not None and savez_id is not None and int(sid) == int(savez_id):
return True
if role in _KLUB_PII_ROLES and kid is not None and klub_id is not None and int(kid) == int(klub_id):
return True
return False
def _audit_oib_access(authorization, resource_type, resource_id, count=1, reason="legitimate_interest"):
"""Log a full-OIB reveal to pgz_sport.audit_events (best-effort, never raises).
Used for GDPR Art.6(1)(f) defensibility. One row per request, not per OIB."""
try:
role, _kid, _sid, email = auth_context(authorization)
if role is None:
return # only log authenticated reveals
from auth.auth_v2 import audit as _audit
# uid not directly available without re-decoding; pull from payload
payload = _decode_jwt_safe(authorization) or {}
uid = payload.get("uid")
_audit(uid, "oib.read", resource_type=resource_type, resource_id=resource_id,
meta={"role": role, "email": email, "count": count, "reason": reason})
except Exception as _e:
# Audit must never break the request path
try:
print(f"[OIB_AUDIT WARN] {_e}")
except Exception:
pass
def blur_oib(v):
if not v: return v
@@ -64,11 +136,27 @@ def blur_text(t, keep=3):
if not t: return t
s=str(t); return s[:keep]+''*(len(s)-keep*2)+s[-keep:] if len(s)>keep*2 else s
def apply_privacy(rows, admin):
def apply_privacy(rows, admin, authorization=None):
"""Apply per-row privacy masking.
`admin`: legacy global override — when True, NOTHING is masked.
`authorization`: when provided, enables per-row scope-aware reveals
(savez_admin sees own savez rows in clear; klub_admin sees own klub
rows in clear). Falls back to row-level mask if scope mismatches.
"""
if admin: return rows
is_list = isinstance(rows, list)
out = []
for r in (rows if isinstance(rows, list) else [rows]):
for r in (rows if is_list else [rows]):
rr = dict(r)
# Per-row scope check (only relevant when authorization is supplied)
row_full = False
if authorization is not None:
row_full = can_see_full_pii(authorization,
klub_id=rr.get("klub_id") or rr.get("id_klub"),
savez_id=rr.get("savez_id") or rr.get("id_savez"))
if row_full:
out.append(rr)
continue
for k, v in list(rr.items()):
if v is None: continue
kl = k.lower()
@@ -80,7 +168,7 @@ def apply_privacy(rows, admin):
elif kl == 'adresa': rr[k] = blur_text(v, 3)
elif 'licenca_broj' in kl: rr[k] = blur_text(v, 2)
out.append(rr)
return out if isinstance(rows, list) else out[0]
return out if is_list else out[0]
app = FastAPI(title="PGŽ Sportski savez ERP/CRM", version="1.0.0")
app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"])
@@ -222,7 +310,16 @@ def health():
@app.get("/api/whoami")
def whoami_v2(authorization: Optional[str] = Header(None)):
return {"role": "admin" if is_admin(authorization) else "viewer", "privacy_active": not is_admin(authorization)}
role, klub_id, savez_id, email = auth_context(authorization)
full_pii = is_admin(authorization)
return {
"role": role or "viewer",
# Legacy boolean retained for backward compat with old frontend code
"is_admin": full_pii,
"privacy_active": not full_pii,
"scope": {"klub_id": klub_id, "savez_id": savez_id},
"email": email,
}
# ==================== DASHBOARD ====================
@app.get("/api/dashboard")
@@ -528,18 +625,28 @@ def list_savezi(authorization: Optional[str] = Header(None), q: Optional[str] =
(SELECT trenera FROM pgz_sport.statistika_saveza WHERE savez_id=s.id AND godina=2024) AS treneri_2024,
(SELECT reprezentativaca FROM pgz_sport.statistika_saveza WHERE savez_id=s.id AND godina=2024) AS repr_2024
FROM pgz_sport.savezi s {where} ORDER BY {sort_col}{collate} {order}""", params)
rows = apply_privacy(rows, is_admin(authorization))
admin = is_admin(authorization)
rows = apply_privacy(rows, admin, authorization=authorization)
if admin:
_audit_oib_access(authorization, "savez_list", None, count=len(rows))
return {"count": len(rows), "rows": rows}
@app.get("/api/savezi/{savez_id}")
def get_savez(savez_id: int):
def get_savez(savez_id: int, authorization: Optional[str] = Header(None)):
rows = fetch("SELECT * FROM pgz_sport.savezi WHERE id=%s", [savez_id])
if not rows:
raise HTTPException(404, "Savez ne postoji")
klubovi = fetch("SELECT * FROM pgz_sport.klubovi WHERE savez_id=%s ORDER BY naziv", [savez_id])
statistika = fetch("SELECT * FROM pgz_sport.statistika_saveza WHERE savez_id=%s ORDER BY godina", [savez_id])
manifestacije = fetch("SELECT * FROM pgz_sport.manifestacije WHERE savez_id=%s", [savez_id])
return {**rows[0], "klubovi": klubovi, "statistika": statistika, "manifestacije": manifestacije}
admin = is_admin(authorization)
savez = rows[0]
if not admin:
savez = apply_privacy(savez, admin, authorization=authorization)
klubovi = apply_privacy(klubovi, admin, authorization=authorization)
else:
_audit_oib_access(authorization, "savez", savez_id, count=1+len(klubovi))
return {**savez, "klubovi": klubovi, "statistika": statistika, "manifestacije": manifestacije}
# ==================== KLUBOVI ====================
@app.get("/api/klubovi")
@@ -566,12 +673,15 @@ def list_klubovi(authorization: Optional[str] = Header(None), q: Optional[str] =
order_sql = "DESC" if order.lower() == "desc" else "ASC"
where_sql = " AND ".join(where) if where else "TRUE"
collate = ' COLLATE "hr-HR-x-icu"' if sort_col in ("klub", "savez", "razina", "region", "grad", "sport") else ""
rows = fetch(f"""SELECT * FROM pgz_sport.v_klubovi_pregled WHERE {where_sql}
rows = fetch(f"""SELECT * FROM pgz_sport.v_klubovi_pregled WHERE {where_sql}
ORDER BY {sort_col}{collate} {order_sql} NULLS LAST""", params)
for r in rows:
if isinstance(r, dict) and r.get('klub') and not r.get('naziv'):
r['naziv'] = r['klub']
rows = apply_privacy(rows, is_admin(authorization))
admin = is_admin(authorization)
rows = apply_privacy(rows, admin, authorization=authorization)
if admin:
_audit_oib_access(authorization, "klub_list", None, count=len(rows))
return {"count": len(rows), "rows": rows}
@app.get("/api/klubovi/{klub_id}")
@@ -628,12 +738,19 @@ def get_klub(klub_id: int, authorization: Optional[str] = Header(None)):
}
klub = rows[0]
if not admin:
klub = apply_privacy(klub, admin)
clanovi = apply_privacy(clanovi, admin)
clanarine = apply_privacy(clanarine, admin)
lijecnicki = apply_privacy(lijecnicki, admin)
# Scope-aware: klub_admin for THIS klub_id should see full PII even if
# is_admin() returns False (savez_admin similarly via klub.savez_id).
scope_full = can_see_full_pii(authorization, klub_id=klub_id, savez_id=klub.get("savez_id"))
if not admin and not scope_full:
klub = apply_privacy(klub, admin, authorization=authorization)
clanovi = apply_privacy(clanovi, admin, authorization=authorization)
clanarine = apply_privacy(clanarine, admin, authorization=authorization)
lijecnicki = apply_privacy(lijecnicki, admin, authorization=authorization)
else:
# Authenticated full-PII access — audit it.
_audit_oib_access(authorization, "klub", klub_id,
count=1 + len(clanovi) + len(clanarine) + len(lijecnicki))
return {**klub, "clanovi": clanovi, "clanarine": clanarine, "lijecnicki": lijecnicki,
"potpore": potpore, "stats": stats}
@@ -696,7 +813,10 @@ def list_clanovi(authorization: Optional[str] = Header(None), q: Optional[str] =
(SELECT SUM(iznos_propisan-iznos_placen) FROM pgz_sport.clanarine WHERE clan_id=c.id AND status!='podmireno') AS dug_clanarine
FROM pgz_sport.clanovi c LEFT JOIN pgz_sport.klubovi k ON k.id=c.klub_id
WHERE {where_sql} ORDER BY {sort_col} {order}""", params)
rows = apply_privacy(rows, is_admin(authorization))
admin = is_admin(authorization)
rows = apply_privacy(rows, admin, authorization=authorization)
if admin:
_audit_oib_access(authorization, "clan_list", None, count=len(rows))
return {"count": len(rows), "rows": rows}
class ClanIn(BaseModel):