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
@@ -159,6 +159,7 @@ td.num { font-family: 'JetBrains Mono', monospace; text-align: right; }
</style>
<link rel="stylesheet" href="/static/shared/sidebar.css">
<script src="/static/shared/sidebar.js" defer data-active="korisnici"></script>
<script src="/static/oib_format.js" defer></script>
</head>
<body>
<div class="app">
@@ -433,7 +434,7 @@ async function loadDashboard() {
`).join('');
}
$('#metaInfo').textContent = `Tenant: ${d.tenant.display_name} · OIB: ${d.tenant.oib || '—'} · ${new Date().toLocaleString('hr-HR')}`;
$('#metaInfo').textContent = `Tenant: ${d.tenant.display_name} · OIB: ${d.tenant.oib ? formatOib(d.tenant.oib) : '—'} · ${new Date().toLocaleString('hr-HR')}`;
}
async function loadERP() {
@@ -473,7 +474,7 @@ async function loadCRM(q='') {
const d = await fetchJSON(url);
if (d && d.klubovi) {
$('#klubTable tbody').innerHTML = d.klubovi.map(k => `
<tr><td><strong>${k.naziv}</strong></td><td>${k.oib || '—'}</td>
<tr><td><strong>${k.naziv}</strong></td><td>${k.oib ? formatOib(k.oib,{klub_id:k.id,savez_id:k.savez_id}) : '—'}</td>
<td>${k.sport || '—'}</td><td>${k.grad || '—'}</td>
<td>${k.email || '—'}</td><td class="num">${fmt(k.clanovi)}</td>
<td class="num">${fmt(k.invoices_count)}</td></tr>
@@ -487,7 +488,7 @@ async function loadOsobe(q='') {
if (d && d.osobe) {
$('#osobeTable tbody').innerHTML = d.osobe.map(o => `
<tr><td>${o.ime}</td><td><strong>${o.prezime}</strong></td>
<td>${o.oib || '—'}</td><td>${o.klub_naziv || '—'}</td>
<td>${o.oib ? formatOib(o.oib,{klub_id:o.klub_id}) : '—'}</td><td>${o.klub_naziv || '—'}</td>
<td>${o.pozicija || '—'}</td><td>${o.email || '—'}</td>
<td>${o.aktivan ? badge('Aktivan', 'green') : badge('Neaktivan', 'gray')}</td></tr>
`).join('');
@@ -500,7 +501,7 @@ async function loadTenants() {
$('#tenantsGrid').innerHTML = d.tenants.map(t => `
<div class="tenant-card">
<div class="name">${t.display_name}</div>
<div class="slug">@${t.slug} · ${t.type} · ${t.oib || 'no OIB'}</div>
<div class="slug">@${t.slug} · ${t.type} · ${t.oib ? formatOib(t.oib) : 'no OIB'}</div>
<div class="stats">
<div class="stat"><strong>${fmt(t.klubovi_count || 0)}</strong>klubovi</div>
<div class="stat"><strong>${statusBadge(t.status).match(/>([^<]+)</)[1]}</strong>status</div>