8e136351f9
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)
165 lines
8.0 KiB
Markdown
165 lines
8.0 KiB
Markdown
# 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-170** — `apply_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.
|