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)
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, formatXXX••••••YY.canSeeFullOib(scope?)→ boolean.getUserCtx()→{role, klub_id, savez_id, email}frompgz_userlocalStorage / 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_ROLESsets_decode_jwt_safe(authorization)— usesauth_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_zzjzcan_see_full_pii(authorization, klub_id, savez_id)— scope-aware gate_audit_oib_access(...)— best-effort audit-log helper (writes topgz_sport.audit_events, action=oib.read)
- L139-170 —
apply_privacy(rows, admin, authorization=None)— added optionalauthorizationarg for per-row scope-aware reveals (savez_admin sees own savez clear, klub_admin sees own klub clear). - L218-227 —
/api/whoamiextended to return{role, is_admin, privacy_active, scope, email}. - L591-595 —
/api/savezilist — passauthorization+ audit on full reveal. - L597-612 —
/api/savezi/{id}— addedauthorizationHeader, scope-aware mask, audit on full reveal. - L644-648 —
/api/klubovilist — audit on full reveal. - L703-715 —
/api/klubovi/{id}—can_see_full_pii(klub_id, klub.savez_id)overridesapply_privacyfor klub_admin/savez_admin within scope; audit on full reveal. - L779-783 —
/api/clanovilist — 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.) hardcodedoib: '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.