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
+5 -4
View File
@@ -230,6 +230,7 @@ table.dt tr:hover td{background:rgba(0,48,135,.1);cursor:pointer}
.sh-stats{grid-template-columns:repeat(3,1fr)}
}
</style>
<script src="/static/oib_format.js" defer></script>
</head>
<body>
@@ -711,7 +712,7 @@ async function openSavezDetail(id){
<div class="srow"><label>Sport</label><span>${d.sport||''}</span></div>
<div class="srow"><label>Predsjednik</label><span style="color:var(--cyan)">${d.predsjednik||''}</span></div>
<div class="srow"><label>Tajnik</label><span style="color:${d.tajnik?'var(--t1)':'var(--red)'}">${d.tajnik||'NULL'}</span></div>
<div class="srow"><label>OIB</label><span class="mn">${d.oib||''}</span></div>
<div class="srow"><label>OIB</label><span class="mn">${d.oib?formatOib(d.oib,{savez_id:d.id}):''}</span></div>
<div class="srow"><label>Email</label><span>${d.email||''}</span></div>
</div>
<div class="tabs" id="sv-tabs">
@@ -815,7 +816,7 @@ async function openKlubDetail(id){
<div class="srow"><label>Predsjednik</label><span style="color:var(--cyan)">${k.predsjednik||''}</span></div>
<div class="srow"><label>Tajnik</label><span style="color:${k.tajnik?'var(--t1)':'var(--red)'}">${k.tajnik||'NULL'}</span></div>
<div class="srow"><label>Savez</label><span>${k.savez_naziv||k.savez||''}</span></div>
<div class="srow"><label>OIB</label><span class="mn">${k.oib||''}</span></div>
<div class="srow"><label>OIB</label><span class="mn">${k.oib?formatOib(k.oib,{klub_id:k.id,savez_id:k.savez_id}):''}</span></div>
<div class="srow"><label>Sjedište</label><span style="font-size:10px">${k.sjediste||''}</span></div>
<div class="srow"><label>Razina</label><span>${k.razina||''}</span></div>
</div>
@@ -909,7 +910,7 @@ async function openSportasProfil(id){
</div>
<div class="tab-c on" id="sp-t-bio">
<div style="background:var(--bg3);border-radius:var(--r);padding:10px">
<div class="srow"><label>OIB</label><span class="mn">${c.oib?'••'+c.oib.slice(-3):''}</span></div>
<div class="srow"><label>OIB</label><span class="mn">${c.oib?formatOib(c.oib,{klub_id:c.klub_id,savez_id:c.savez_id}):''}</span></div>
<div class="srow"><label>Datum rodjenja</label><span>${c.datum_rodenja||c.datum_rodjenja||''}</span></div>
<div class="srow"><label>Spol</label><span>${c.spol||''}</span></div>
<div class="srow"><label>Visina/Težina</label><span>${c.visina_cm||''} cm / ${c.tezina_kg||''} kg</span></div>
@@ -1025,7 +1026,7 @@ async function loadClanarine(){
return `<tr onclick="openSportasProfil(${r.clan_id})" style="cursor:pointer">
<td style="display:flex;align-items:center;gap:7px">
<div style="width:28px;height:28px;border-radius:4px;background:var(--bg3);overflow:hidden;display:flex;align-items:center;justify-content:center;font-size:12px">${r.slika_url?`<img src="${r.slika_url}" style="width:100%;height:100%;object-fit:cover" onerror="this.style.display='none'">`:r.spol==='Ž'?'👩':'👤'}</div>
<div><div style="font-weight:600">${r.ime||''} ${r.prezime||''}</div><div style="font-size:8px;color:var(--t4);font-family:var(--mono)">${r.oib||''}</div></div>
<div><div style="font-weight:600">${r.ime||''} ${r.prezime||''}</div><div style="font-size:8px;color:var(--t4);font-family:var(--mono)">${r.oib?formatOib(r.oib,{klub_id:r.klub_id,savez_id:r.savez_id}):''}</div></div>
</td>
<td style="font-size:10px;color:var(--t2)">${['','I','II','III','IV','V','VI'][r.hoo_kategorija||0]||''}</td>
<td class="mn">${r.godina||''}</td>