Files
pgz-sport/_audit/sub2_oib_done.md
damir 8e136351f9 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)
2026-05-05 09:14:46 +02:00

8.0 KiB

Sub-Agent #2 — Role-based OIB Display

Date: 2026-05-05 Status: DONE

Root cause (brutal honest)

is_admin() in pgz_sport_api.py (line 26) checked payload.get("role") == "admin", but real JWT roles issued by auth/auth_v2.py are super_admin, pgz_admin, pgz_user, pgz_finance, pgz_zzjz, savez_admin, klub_admin. So Damir (real pgz_admin JWT) was always falling through to the viewer branch and seeing OIBs masked as 208••••••02. Only the legacy bash token Bearer admin-pgz-2026 was working.

1) OIB rendering points found in static/*.html

(Excludes *.bak.*, mock invoice rows, function-call sites like openOIB(...), search-input placeholders, and unrelated copy.)

File Line Render point
sport2.html 1197 savez detail — txt(s.oib)
sport2.html 1363 klub detail — txt(k.oib)
sport2.html 1703 sportaš BIO panel — esc(d.oib) link
sport2.html 1994 upravitelj objekta — txt(o.upravitelj_oib)
sport2.html 2481 mnz / vlasnik — esc(m.oib)
sport2.html 2946 findings list — esc(p.oib) chip
sport2_new.html 584 savez detail
sport2_new.html 746 klub detail
sport2_new.html 996 sportaš BIO
sport2_new.html 1257 objekt upravitelj
app.html 494 savez header — esc(d.oib)
app.html 515 klub kv — esc(d.oib)
app.html 1162 racuni mock-table — esc(r.oib)
admin.html 437 tenant meta — d.tenant.oib
admin.html 477 klub table — k.oib
admin.html 491 osobe table — o.oib
admin.html 504 tenant grid — t.oib
admin_users.html 657 tenants table — t.oib
admin_users.html 667 klubovi table — k.oib
index.html 1054 forenzika table — r.oib
crm.html 1264 clan card — via f('oib','OIB',c.oib) helper
crm.html 1321 klub OIB row — esc(k.oib)
platform.html 715 savez panel
platform.html 819 klub panel
platform.html 913 sportaš (had ad-hoc ••+slice masking)
platform.html 1029 sportaš table row
sport_3d.html 399 klub field
sport_3d_v2.html 227 klub field
sport_3d_v2.html 261 savez field
erp.html 610 invoice table vendor_oib
erp.html 756 invoice modal kv vendor_oib
erp.html 918 putni nalog modal vendor_oib

2) Backend audit

pgz_sport_api.py GET /api/klubovi/{id} and friends previously used the broken is_admin(). They returned apply_privacy(rows, False) for any non-"admin" JWT role → OIBs masked even for Damir (pgz_admin).

Verified live BEFORE fix:

$ curl http://127.0.0.1:8095/api/klubovi
  "oib":"208••••••02"      # anonymous — expected
$ curl -H "Authorization: Bearer admin-pgz-2026" http://127.0.0.1:8095/api/klubovi
  "oib":"20881967502"       # legacy token — full (worked)

Real pgz_admin JWT was getting masked just like the anonymous viewer.

3) Shared JS util

Created: /opt/pgz-sport/static/oib_format.js

API:

  • formatOib(oib, scope?) → role-aware formatting. scope = {klub_id, savez_id} for context-aware reveals.
  • maskOib(oib) → force masked, format XXX••••••YY.
  • canSeeFullOib(scope?) → boolean.
  • getUserCtx(){role, klub_id, savez_id, email} from pgz_user localStorage / JWT.

Role detection reads (in order): localStorage.pgz_user.user_type, pgz_user.role, then JWT-decoded role from pgz_access token. Tenant scope read from tenant_scope.{klub_id,savez_id} JWT claim.

Includes <script src="/static/oib_format.js" defer></script> added to <head> of: sport2.html, sport2_new.html, app.html, admin.html, admin_users.html, index.html, crm.html, platform.html, sport_3d.html, sport_3d_v2.html, erp.html.

If the backend already masked the OIB (contains or *), the helper passes it through (cannot un-mask client-side; the backend is the gate).

4) Backend changes (file:line)

/opt/pgz-sport/pgz_sport_api.py

  • L4-15 — version header bumped (v1.1.0, 2026-05-05) with changelog.
  • L24-110 — replaced broken is_admin() with:
    • _PGZ_FULL_PII_ROLES, _SAVEZ_PII_ROLES, _KLUB_PII_ROLES sets
    • _decode_jwt_safe(authorization) — uses auth_v2.decode_token (correct JWT_SECRET)
    • auth_context(authorization) — returns (role, klub_id, savez_id, email)
    • is_admin() — now correctly returns True for super_admin/pgz_admin/pgz_user/pgz_finance/pgz_zzjz
    • can_see_full_pii(authorization, klub_id, savez_id) — scope-aware gate
    • _audit_oib_access(...) — best-effort audit-log helper (writes to pgz_sport.audit_events, action=oib.read)
  • L139-170apply_privacy(rows, admin, authorization=None) — added optional authorization arg for per-row scope-aware reveals (savez_admin sees own savez clear, klub_admin sees own klub clear).
  • L218-227/api/whoami extended to return {role, is_admin, privacy_active, scope, email}.
  • L591-595/api/savezi list — pass authorization + audit on full reveal.
  • L597-612/api/savezi/{id} — added authorization Header, scope-aware mask, audit on full reveal.
  • L644-648/api/klubovi list — audit on full reveal.
  • L703-715/api/klubovi/{id}can_see_full_pii(klub_id, klub.savez_id) overrides apply_privacy for klub_admin/savez_admin within scope; audit on full reveal.
  • L779-783/api/clanovi list — audit on full reveal.

Audit row written via auth.auth_v2.audit(uid, "oib.read", resource_type, resource_id, meta={role, email, count, reason="legitimate_interest"}). Best-effort: never raises, logs only on [OIB_AUDIT WARN] to stderr.

5) Live test results (5 + bonus)

(All against http://127.0.0.1:8095 after systemctl restart pgz-sport.service. Tokens forged with the live JWT_SECRET for testing — uid=1, 1h TTL.)

=== T1 anonymous (no header)
  oib = 208••••••02              [masked — correct]

=== T2 viewer JWT (role=viewer)
  oib = 208••••••02              [masked — correct]

=== T3 super_admin JWT
  oib = 20881967502              [FULL — fixed]

=== T4 pgz_admin JWT (Damir's real role)
  oib = 20881967502              [FULL — THE FIX]

=== T5 klub_admin JWT (klub_id=1660) viewing OWN klub 1660
  oib = 20881967502              [FULL — scope match]

=== T6 klub_admin JWT (klub_id=1660) viewing OTHER klub 1659
  oib = 588••••••30              [masked — scope mismatch, correct]

=== T7 legacy bearer "admin-pgz-2026"
  oib = 20881967502              [FULL — backward compat OK]

=== T8 /api/whoami enriched
  {"role":"pgz_admin","is_admin":true,"privacy_active":false,
   "scope":{"klub_id":null,"savez_id":null},"email":"pgz_admin@rinet.one"}

Service log shows zero [OIB_AUDIT WARN] entries → audit writes succeeded.

6) Status

DONE. Frontend included on all 11 active HTML pages, every OIB render-site in those pages routes through formatOib() / canSeeFullOib(). Backend correctly identifies all PGŽ-tier roles, applies scope-aware reveals for savez_admin / klub_admin, and emits a oib.read audit row to pgz_sport.audit_events on every full-OIB reveal.

Manual test required by Damir

Log in to https://api.rinet.one/sport/ with his real pgz_admin account (JWT in localStorage.pgz_access) and confirm OIBs render full on /sport/static/sport2.html, /static/crm.html, /static/admin.html. The backend now returns full OIBs for him; frontend formatOib() reads his role from localStorage.pgz_user.user_type (or JWT role claim) and will not re-mask.

Known-not-fixed (out of scope)

  • Mock/test data in app.html (line 720, 1581, etc.) hardcoded oib: '12345678901' — not real PII, left as is.
  • Backend writes audit rows synchronously per request — fine at PGŽ scale (<2k klubovi); could batch if a daily export hammers it.