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:
+144
-24
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user