CRISIS V3: definitive apiAuth + mobile hamburger + Playwright E2E test

apiAuth in app.html:
- Pre-checks JWT exp client-side BEFORE making request
- On expired: clears localStorage + redirects /login?reason=expired
- On 401 from server: clears + redirects /login?reason=unauthorized
- Single-flight redirect via window.__pgz_redirecting flag

login.html:
- Toast for ?reason=expired (red) / ?reason=unauthorized (orange)

app.html mobile:
- Hamburger button injected into topbar (.tb)
- Mobile CSS: sidebar slide-in -280→0, backdrop overlay, full-width drill-down
- toggleMobileSidebar() global function
- @media (max-width:768px) display:inline-flex, sidebar fixed pos

scripts/playwright_e2e.py:
- Desktop test (1280x800): login, JWT persist, profile, logo, logout
- Mobile test (375x812 iPhone X): viewport, login flow, hamburger, no h-scroll
- Output: _audit/playwright_<TS>/results.json + screenshots/*.png

Reproducible: TS=YYYYmmdd_HHMM python3 scripts/playwright_e2e.py
This commit is contained in:
2026-05-05 09:21:39 +02:00
parent 8e136351f9
commit dd2f7daaf8
25 changed files with 523 additions and 41 deletions
+65 -2
View File
@@ -309,6 +309,38 @@ table tbody tr:hover{background:var(--bg3)}
@media (max-width: 768px) {
.sb-backdrop.show { display: block; }
}
/* Hamburger button visibility (CRISIS V3) */
.mobile-menu-btn {
display: none;
background: var(--bg2,#1a1a1e);
color: var(--t1,#fff);
border: 1px solid var(--rim,#2a2a2e);
padding: 6px 10px;
font-size: 18px;
border-radius: 4px;
cursor: pointer;
margin-right: 8px;
}
@media (max-width: 768px) {
.mobile-menu-btn { display: inline-flex !important; align-items: center; justify-content: center; }
.sb {
position: fixed !important; left: -280px !important; top: 0 !important;
width: 260px !important; height: 100vh !important; z-index: 1000 !important;
transition: left 0.3s ease !important;
}
.sb.mobile-open { left: 0 !important; }
.main { margin-left: 0 !important; }
.sb-backdrop {
display: none; position: fixed; inset: 0;
background: rgba(0,0,0,.55); z-index: 999;
}
.sb-backdrop.show { display: block !important; }
/* Center mobile content */
.content, .main { padding: 12px !important; }
.tb { padding: 8px 12px !important; }
}
</style>
<link rel="stylesheet" href="/static/shared/sidebar.css">
<script src="/static/shared/sidebar.js" defer data-active="profil"></script>
@@ -337,6 +369,7 @@ table tbody tr:hover{background:var(--bg3)}
<main class="main">
<div class="tb">
<button class="mobile-menu-btn" onclick="toggleMobileSidebar()" aria-label="Menu" type="button"></button>
<div>
<div class="tb-t" id="tb-t">Dashboard</div>
<div class="tb-s" id="tb-s">Pregled stanja</div>
@@ -398,11 +431,41 @@ function getToken(){
async function apiAuth(path, opts){
opts = opts || {};
const h = Object.assign({}, opts.headers || {});
const tok = getToken(); if(tok) h['Authorization'] = 'Bearer '+tok;
const tok = getToken();
// ━━━ JWT EXPIRY PRE-CHECK ━━━
if(tok){
try{
const payload = JSON.parse(atob(tok.split('.')[1]));
if(payload.exp && payload.exp * 1000 < Date.now()){
console.warn('[apiAuth] JWT expired client-side, redirecting');
['pgz_access','pgz_refresh','pgz_user','jwt','access_token'].forEach(k => {
try{localStorage.removeItem(k); sessionStorage.removeItem(k);}catch(e){}
});
if(!window.__pgz_redirecting){ window.__pgz_redirecting = true; window.location.href = '/login?reason=expired'; }
return {__unauthorized:true, status:401};
}
}catch(e){ /* token not parseable, continue and let server respond */ }
h['Authorization'] = 'Bearer '+tok;
}
if(opts.body && !(opts.body instanceof FormData) && !h['Content-Type']) h['Content-Type'] = 'application/json';
try {
const r = await fetch(API+path, Object.assign({}, opts, {headers:h}));
if(r.status === 401){ return {__unauthorized:true, status:401}; }
if(r.status === 401){
// ━━━ GLOBAL 401 HANDLER — clear + redirect ━━━
console.warn('[apiAuth] 401 from server, clearing localStorage + redirecting');
['pgz_access','pgz_refresh','pgz_user','jwt','access_token'].forEach(k => {
try{localStorage.removeItem(k); sessionStorage.removeItem(k);}catch(e){}
});
// Don't redirect from /login itself; allow profile page to handle
const onLogin = location.pathname.includes('/login');
if(!onLogin && !window.__pgz_redirecting){
window.__pgz_redirecting = true;
window.location.href = '/login?reason=unauthorized';
}
return {__unauthorized:true, status:401};
}
if(!r.ok) return {__error:true, status:r.status};
if(r.headers.get('content-type')?.includes('application/json')) return await r.json();
return {__ok:true};
+17
View File
@@ -582,5 +582,22 @@ $('#cookieMore').addEventListener('click', e => { e.preventDefault(); $('#privac
}, 100);
})();
</script>
<script>
// Auto-toast za reason=expired / unauthorized (CRISIS V3)
(function(){
const params = new URLSearchParams(window.location.search);
const reason = params.get('reason');
if(!reason) return;
setTimeout(() => {
const div = document.createElement('div');
div.id = 'pgz-reason-toast';
div.style.cssText = 'position:fixed;top:20px;left:50%;transform:translateX(-50%);background:'+(reason==='expired'?'#c0392b':'#e67e22')+';color:#fff;padding:12px 20px;border-radius:6px;z-index:9999;font-size:14px;box-shadow:0 4px 12px rgba(0,0,0,.3);font-family:system-ui,sans-serif';
div.textContent = reason==='expired' ? 'Sesija je istekla. Molim prijavi se ponovno.' : (reason==='unauthorized' ? 'Sesija je nevažeća. Prijavi se opet.' : 'Potrebna prijava.');
document.body.appendChild(div);
setTimeout(() => div.remove(), 6000);
}, 100);
})();
</script>
</body>
</html>