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:
+65
-2
@@ -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};
|
||||
|
||||
Reference in New Issue
Block a user