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
+164
View File
@@ -0,0 +1,164 @@
# 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.