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
+38 -6
View File
@@ -223,9 +223,41 @@ a.tag:hover,.tag[onclick]:hover{transform:translateY(-1px);filter:brightness(1.1
.main{margin-left:0}
.pp-stats{grid-template-columns:repeat(3,1fr)}
}
/* === MOBILE RESPONSIVE (CRISIS FIX) === */
@media (max-width: 768px) {
body { font-size: 13px; }
.header { flex-direction: column !important; gap: 8px !important; padding: 10px !important; }
.header h1 { font-size: 16px !important; }
.nav-tabs { overflow-x: auto; white-space: nowrap; -webkit-overflow-scrolling: touch; }
.nav-tabs .tab { display: inline-block !important; }
.card { padding: 10px !important; }
.kpi-grid { grid-template-columns: 1fr 1fr !important; gap: 6px !important; }
.kpi-v { font-size: 18px !important; }
.klubovi-grid, .grid-2, .grid-3, .grid-4 {
grid-template-columns: 1fr !important;
}
/* Tables → horizontal scroll */
table { font-size: 11px !important; min-width: 480px; }
.table-container, .card { overflow-x: auto; }
/* Drill-down panel full-width */
#panel { width: 100vw !important; max-width: 100vw !important; right: -100vw !important; }
#panel.open { right: 0 !important; }
/* Buttons */
.btn { padding: 8px 12px !important; font-size: 13px !important; }
/* Center mobile content */
.container, main { padding: 8px !important; max-width: 100% !important; margin: 0 !important; }
}
</style>
<link rel="stylesheet" href="/static/shared/sidebar.css">
<script src="/static/shared/sidebar.js" defer data-active="dashboard"></script>
<script src="/static/oib_format.js" defer></script>
</head>
<body>
@@ -1193,7 +1225,7 @@ async function openSavez(id){
<div class="card">
<div class="card-h"><div class="card-t">📋 Osnovne informacije</div></div>
<div class="kv">
<div class="k">OIB</div><div class="v">${txt(s.oib)}</div>
<div class="k">OIB</div><div class="v">${s.oib?formatOib(s.oib,{savez_id:s.id}):'—'}</div>
<div class="k">Adresa</div><div class="v">${txt(s.adresa)}</div>
<div class="k">Predsjednik</div><div class="v">${txt(s.predsjednik)}</div>
<div class="k">Tajnik</div><div class="v">${txt(s.tajnik)}</div>
@@ -1359,7 +1391,7 @@ async function openKlub(id){
<div id="k-info" class="ktab">
<div class="kv">
<div class="k">Naziv</div><div class="v">${esc(k.naziv||'')}</div>
<div class="k">OIB</div><div class="v">${txt(k.oib)}</div>
<div class="k">OIB</div><div class="v">${k.oib?formatOib(k.oib,{klub_id:k.id,savez_id:k.savez_id}):'—'}</div>
<div class="k">Sport</div><div class="v">${txt(k.sport)}</div>
<div class="k">Razina</div><div class="v">${txt(k.razina)}</div>
<div class="k">Savez</div><div class="v">${txt(k.savez_naziv)}</div>
@@ -1699,7 +1731,7 @@ async function openSportas(id){
<div id="p-bio" class="ptab" style="display:none">
<div class="kv">
<div class="k">OIB</div><div class="v">${d.oib?'<a class="link-chip" onclick="openOIB(&quot;'+esc(d.oib)+'&quot;)">'+esc(d.oib)+'</a>':'—'}</div>
<div class="k">OIB</div><div class="v">${d.oib?(canSeeFullOib({klub_id:d.klub_id,savez_id:d.savez_id})?'<a class="link-chip" onclick="openOIB(&quot;'+esc(d.oib)+'&quot;)">'+esc(d.oib)+'</a>':maskOib(d.oib)):'—'}</div>
<div class="k">Datum rođenja</div><div class="v">${fmtDate(dob)}</div>
<div class="k">Mjesto rođenja</div><div class="v">${txt(d.mjesto_rodjenja||d.mjesto_rodenja)}</div>
<div class="k">Spol</div><div class="v">${txt(d.spol)}</div>
@@ -1990,7 +2022,7 @@ function openObjekt(id){
<div class="k">Adresa</div><div class="v">${txt(o.adresa)}</div>
<div class="k">Grad</div><div class="v">${txt(o.grad)}</div>
<div class="k">Upravitelj</div><div class="v">${txt(o.upravitelj)}</div>
<div class="k">OIB</div><div class="v">${txt(o.upravitelj_oib)}</div>
<div class="k">OIB</div><div class="v">${o.upravitelj_oib?formatOib(o.upravitelj_oib):'—'}</div>
<div class="k">Kapacitet</div><div class="v">${o.kapacitet?fmtNum(o.kapacitet)+' mjesta':'—'}</div>
<div class="k">Veličina</div><div class="v">${txt(o.veličina)}</div>
<div class="k">Sportovi</div><div class="v">${(o.sportovi||[]).map(s=>'<span class="tag b">'+esc(s)+'</span>').join(' ')||'—'}</div>
@@ -2477,7 +2509,7 @@ function openMrezaNode(n){
<div class="k">ID</div><div class="v" style="font-family:var(--mono);font-size:11px">${esc(n.id)}</div>
<div class="k">Tip</div><div class="v">${esc(n.type)}</div>
<div class="k">Naziv</div><div class="v">${esc(n.label)}</div>
${m.oib?'<div class="k">OIB</div><div class="v" style="font-family:var(--mono)">'+esc(m.oib)+'</div>':''}
${m.oib?'<div class="k">OIB</div><div class="v" style="font-family:var(--mono)">'+esc(formatOib(m.oib,{klub_id:m.klub_id,savez_id:m.savez_id}))+'</div>':''}
${m.city?'<div class="k">Grad</div><div class="v">'+esc(m.city)+'</div>':''}
${m.buyer_contracts!=null?'<div class="k">Ugovori kao kupac</div><div class="v">'+m.buyer_contracts+'</div>':''}
${m.buyer_value!=null?'<div class="k">Vrijednost (kupac)</div><div class="v">'+fmtEurFull(m.buyer_value)+'</div>':''}
@@ -2942,7 +2974,7 @@ async function runForensicScan(){
<div class="alert-card ${cls}">
<div style="display:flex;justify-content:space-between;align-items:flex-start;gap:12px">
<div style="flex:1;min-width:0">
<div class="at">${esc(p.name)} <span class="tag b">id ${p.id}</span> ${p.oib?'<a class="tag" onclick="openOIB(&quot;'+esc(p.oib)+'&quot;)" style="cursor:pointer">OIB '+esc(p.oib)+'</a>':''}</div>
<div class="at">${esc(p.name)} <span class="tag b">id ${p.id}</span> ${p.oib?(canSeeFullOib({klub_id:p.klub_id,savez_id:p.savez_id})?'<a class="tag" onclick="openOIB(&quot;'+esc(p.oib)+'&quot;)" style="cursor:pointer">OIB '+esc(p.oib)+'</a>':'<span class="tag">OIB '+esc(maskOib(p.oib))+'</span>'):''}</div>
<div class="ad">${p.function?esc(p.function):''}${p.party?' · '+esc(p.party):''}${p.county?' · '+esc(p.county):''}</div>
<div style="margin-top:6px;font-size:11px;color:var(--t2)">
🔗 ${(p.links||[]).length} povezanih entiteta