import { connectSocket } from './socket.js'; import * as dashboard from './views/dashboard.js'; import * as deviceDetail from './views/device-detail.js'; import * as contentLibrary from './views/content-library.js'; import * as settings from './views/settings.js'; import * as login from './views/login.js'; import * as billing from './views/billing.js'; import * as layoutEditor from './views/layout-editor.js'; import * as schedule from './views/schedule.js'; import * as widgets from './views/widgets.js'; import * as videoWall from './views/video-wall.js'; import * as reports from './views/reports.js'; import * as activity from './views/activity.js'; import * as kiosk from './views/kiosk.js'; import * as onboarding from './views/onboarding.js'; import * as help from './views/help.js'; import * as teams from './views/teams.js'; import * as admin from './views/admin.js'; import * as adminPlayerDebug from './views/admin-player-debug.js'; import * as designer from './views/designer.js'; import * as playlists from './views/playlists.js'; import * as workspaceMembers from './views/workspace-members.js'; import * as forcePasswordChange from './views/force-password-change.js'; import * as noWorkspace from './views/no-workspace.js'; import { applyBranding } from './branding.js'; import { t } from './i18n.js'; import { isPlatformAdmin } from './utils.js'; import { renderWorkspaceSwitcher } from './components/workspace-switcher.js'; import { showToast } from './components/toast.js'; import { api } from './api.js'; const app = document.getElementById('app'); const sidebar = document.querySelector('.sidebar'); let currentView = null; // ==================== Slice 2C: accept-invite plumbing ==================== // // Flow shape (covers all six auth entry points - login, register, support, // Google, Microsoft, first-user-setup - because they all funnel through // onAuthSuccess() in login.js which calls window.location.reload()): // // 1. Hash route #/accept-invite/{id}: // - unauthed: stash inviteId in localStorage, redirect to login // - authed: call consumeAcceptInvite() directly (no stash) // 2. App boot (every route() call once auth checks pass): if a valid // non-stale stash is present, fire consumeAcceptInvite. After login // reload lands here and picks it up automatically. // 3. consumeAcceptInvite on success: stash toast text, switch workspace, // reload. Reload re-fires route() which picks up the toast stash and // shows it on dashboard. Reload is needed for the new JWT/socket/ // sidebar /me to pick up the new workspace context. // 4. consumeAcceptInvite on error: showToast directly + clear stash. // No reload (no state change to propagate). const PENDING_INVITE_KEY = 'pending_invite'; const PENDING_INVITE_TOAST_KEY = 'pending_invite_toast'; // Mirrors the backend INVITE_EXPIRY_DAYS default (7). If an operator changes // the backend default, this should be updated to match - tracked in handoff. const INVITE_EXPIRY_DAYS_FRONTEND = 7; // Non-reentrant guard: route() can fire multiple times (hashchange events). // Once consume is in flight, additional calls no-op until reload completes. let _acceptInFlight = false; function stashPendingInvite(inviteId) { localStorage.setItem(PENDING_INVITE_KEY, JSON.stringify({ inviteId, stashedAt: Math.floor(Date.now() / 1000), })); } function readPendingInvite() { const raw = localStorage.getItem(PENDING_INVITE_KEY); if (!raw) return null; let parsed; try { parsed = JSON.parse(raw); } catch { localStorage.removeItem(PENDING_INVITE_KEY); return null; } if (!parsed?.inviteId || !parsed?.stashedAt) { localStorage.removeItem(PENDING_INVITE_KEY); return null; } const ageSecs = Math.floor(Date.now() / 1000) - parsed.stashedAt; if (ageSecs > INVITE_EXPIRY_DAYS_FRONTEND * 86400) { localStorage.removeItem(PENDING_INVITE_KEY); return null; } return parsed.inviteId; } function clearPendingInvite() { localStorage.removeItem(PENDING_INVITE_KEY); } // Map backend error message text to a translated toast string. We match // English text because api.js doesn't surface HTTP status codes today; // refactor to err.status when that lands - tracked in handoff doc. function mapAcceptError(err) { const msg = err?.message || ''; if (/Invite not found/i.test(msg)) return t('accept.error.not_found'); if (/Invite has expired|Workspace no longer exists/i.test(msg)) return t('accept.error.expired'); if (/different email address/i.test(msg)) return t('accept.error.wrong_account'); return t('accept.error.generic'); } async function consumeAcceptInvite(inviteId) { if (_acceptInFlight) return; _acceptInFlight = true; try { const result = await api.acceptInvite(inviteId); // Switch to the joined workspace. New JWT carries the workspace context; // reload picks it up for sidebar /me + socket rooms + data fetches. If // the switch fails, log and reload anyway - the membership was created // so the user can switch manually via the dropdown. try { const sw = await api.switchWorkspace(result.workspace_id); if (sw?.token) localStorage.setItem('token', sw.token); } catch (e) { console.warn('switchWorkspace after accept failed (non-fatal):', e.message); } // Stash the toast text in a scoped key (not a generic pending-toast // channel) so app boot below fires it after reload. const toastKey = result.already_member ? 'accept.already_member' : 'accept.success'; localStorage.setItem(PENDING_INVITE_TOAST_KEY, JSON.stringify({ message: t(toastKey, { name: result.workspace_name }), kind: 'success', })); clearPendingInvite(); // history.replaceState mutates the hash WITHOUT firing hashchange. // Important: a plain `location.hash = '#/'` would fire hashchange // synchronously, causing route() to fire a second time before the // reload runs - that second route() call would consume the toast key // and attach the toast to a DOM that's about to be destroyed by the // reload. Using replaceState bypasses that race so the post-reload // route() is the only one that picks up the toast. history.replaceState(null, '', window.location.pathname + '#/'); window.location.reload(); } catch (err) { showToast(mapAcceptError(err), 'error'); clearPendingInvite(); _acceptInFlight = false; } } // Fires once per page load (single-shot key in localStorage). If the // previous routeApp cycle stashed a toast across reload, show it now. function consumePendingInviteToast() { const raw = localStorage.getItem(PENDING_INVITE_TOAST_KEY); if (!raw) return; localStorage.removeItem(PENDING_INVITE_TOAST_KEY); try { const { message, kind } = JSON.parse(raw); if (message) showToast(message, kind || 'info'); } catch {} } // Map nav-link data-view to its translation key. const NAV_LABEL_KEYS = { dashboard: 'nav.displays', content: 'nav.content', playlists: 'nav.playlists', layouts: 'nav.layouts', widgets: 'nav.widgets', schedule: 'nav.schedule', walls: 'nav.walls', reports: 'nav.reports', kiosk: 'nav.kiosk', designer: 'nav.designer', activity: 'nav.activity', teams: 'nav.teams', members: 'nav.members', help: 'nav.help', settings: 'nav.settings', billing: 'nav.subscription', admin: 'nav.admin', }; function renderNavLabels() { document.querySelectorAll('.nav-link').forEach((link) => { const key = NAV_LABEL_KEYS[link.dataset.view]; if (!key) return; const span = link.querySelector('span'); if (span) span.textContent = t(key); }); } // Translate any element marked with data-i18n / data-i18n-placeholder / // data-i18n-html. Runs on init and on every language change. Used for static // HTML in index.html (e.g. the Add-Display modal) where t() can't be inlined // at template time. function translateStaticDom(root = document) { root.querySelectorAll('[data-i18n]').forEach((el) => { const key = el.getAttribute('data-i18n'); el.textContent = t(key); }); root.querySelectorAll('[data-i18n-html]').forEach((el) => { el.innerHTML = t(el.getAttribute('data-i18n-html')); }); root.querySelectorAll('[data-i18n-placeholder]').forEach((el) => { el.placeholder = t(el.getAttribute('data-i18n-placeholder')); }); } function isAuthenticated() { return !!localStorage.getItem('token'); } function getCurrentUser() { try { return JSON.parse(localStorage.getItem('user')); } catch { return null; } } // #12: true when a signed-in user provably has zero accessible workspaces and // no platform-level reach. Requires accessible_workspaces to be present (only // /me populates it) - undefined means "not loaded yet", so we DON'T trigger and // fall through to the normal (workspace-empty-safe) views until /me resolves. function hasNoAccessibleWorkspace(u) { return !!u && Array.isArray(u.accessible_workspaces) && u.accessible_workspaces.length === 0 && !u.current_workspace_id && !isPlatformAdmin(u); } // Refresh the cached user from the server. The server reads plan_id fresh // from the DB on every request, but the frontend only wrote `user` into // localStorage at login — so plan/role changes made by an admin weren't // visible until the user logged out and back in. async function refreshCurrentUser() { const token = localStorage.getItem('token'); if (!token) return; try { const res = await fetch('/api/auth/me', { headers: { Authorization: `Bearer ${token}` } }); if (!res.ok) return; const fresh = await res.json(); localStorage.setItem('user', JSON.stringify(fresh)); // Re-render the workspace switcher on every /me refresh - cheap, and keeps // the dropdown in sync if a workspace was added/removed in another tab. renderWorkspaceSwitcher(fresh); window.dispatchEvent(new CustomEvent('user-refreshed', { detail: fresh })); // #12: /me is the first place accessible_workspaces is known. If it resolves // to zero (org-less user), send them to the empty state now - on a fresh // load route() may have already rendered the dashboard before /me returned. // Guard against the login / change-password / already-there screens to avoid // a redirect loop. const hash = window.location.hash || '#/'; if (hasNoAccessibleWorkspace(fresh) && hash !== '#/no-workspace' && hash !== '#/login' && hash !== '#/change-password') { window.location.hash = '#/no-workspace'; } } catch {} } // Help tips are the main in-product explanation, and they were reachable by HOVER only — // invisible on a tablet or phone, and unreachable from a keyboard. Bound once at the document // level so every view's tips work without each having to opt in: tapping toggles one, Escape or // a tap elsewhere closes it, and the marker is made focusable so Tab can reach it. let tipsBound = false; function enableHelpTips() { document.querySelectorAll('.help-tip:not([tabindex])').forEach((el) => { el.setAttribute('tabindex', '0'); el.setAttribute('role', 'button'); el.setAttribute('aria-label', el.dataset.tip || 'Help'); }); if (tipsBound) return; tipsBound = true; // A native title= is hover-only too, so icon-only buttons (rename a wall, remove a device // from one, manage members) explain themselves on a desktop and say nothing at all on a // touchscreen. Long-press one and show its label as a toast — the text already exists and is // translated, it simply had no way to reach a finger. let pressTimer = null; const cancelPress = () => { clearTimeout(pressTimer); pressTimer = null; }; document.addEventListener('touchstart', (e) => { const el = e.target.closest('[title]'); if (!el) return; const label = el.getAttribute('title'); if (!label) return; pressTimer = setTimeout(() => showToast(label, 'info'), 500); }, { passive: true }); ['touchend', 'touchmove', 'touchcancel'].forEach(ev => document.addEventListener(ev, cancelPress, { passive: true })); // Views render from ~20 call sites and modals appear later still, so watch the DOM rather // than trying to call this after each one — a tip added by a route nobody remembered to hook // would otherwise be keyboard-unreachable again. const host = document.getElementById('app') || document.body; let pending = null; new MutationObserver(() => { clearTimeout(pending); pending = setTimeout(() => { document.querySelectorAll('.help-tip:not([tabindex])').forEach((el) => { el.setAttribute('tabindex', '0'); el.setAttribute('role', 'button'); el.setAttribute('aria-label', el.dataset.tip || 'Help'); }); }, 50); }).observe(host, { childList: true, subtree: true }); document.addEventListener('click', (e) => { const tip = e.target.closest('.help-tip'); document.querySelectorAll('.help-tip.is-open').forEach((o) => { if (o !== tip) o.classList.remove('is-open'); }); if (tip) { e.preventDefault(); tip.classList.toggle('is-open'); } }); document.addEventListener('keydown', (e) => { if (e.key === 'Escape') document.querySelectorAll('.help-tip.is-open').forEach((o) => o.classList.remove('is-open')); if ((e.key === 'Enter' || e.key === ' ') && e.target.classList && e.target.classList.contains('help-tip')) { e.preventDefault(); e.target.classList.toggle('is-open'); } }); } function route() { // Cleanup previous view if (currentView && currentView.cleanup) currentView.cleanup(); const hash = window.location.hash || '#/'; // Slice 2C - direct hits on #/accept-invite/{id}. Handle BEFORE the // auth-redirect-to-login because an unauthed visit needs to stash the // inviteId so it survives the redirect. if (hash.startsWith('#/accept-invite/')) { const inviteId = hash.split('#/accept-invite/')[1].split('/')[0]; if (inviteId) { if (!isAuthenticated()) { stashPendingInvite(inviteId); window.location.hash = '#/login'; return; } consumeAcceptInvite(inviteId); // helper handles routing (reload to '#/') return; } } // Password-reset links arrive from email on a browser that is by definition NOT logged // in, and carry a one-time token in the hash. This must be handled BEFORE the redirect // below: rewriting the hash would discard the token and the emailed link would silently // do nothing. The login view reads the token off the hash and shows the new-password form. const isResetRoute = hash.startsWith('#/reset-password'); // Auth check - redirect to login if not authenticated if (!isAuthenticated() && hash !== '#/login' && !isResetRoute) { window.location.hash = '#/login'; return; } // If authenticated and on login page, redirect to dashboard or onboarding if (isAuthenticated() && (hash === '#/login' || isResetRoute)) { window.location.hash = localStorage.getItem('rd_onboarded') ? '#/' : '#/onboarding'; return; } // Slice 2C - past the auth gates. (a) Show any toast stashed across the // accept-invite reload boundary. (b) If a stash exists (from an unauthed // accept-invite visit + subsequent login/register), consume it now. The // helper's in-flight guard prevents double-fire on subsequent hashchanges. if (isAuthenticated()) { consumePendingInviteToast(); const stashedInviteId = readPendingInvite(); if (stashedInviteId) { consumeAcceptInvite(stashedInviteId); return; } } // #10: forced first-login password change. An admin-provisioned user carries // must_change_password until they set their own password. Block every other // authenticated view and force them to the change-password screen; the server // clears the flag on a successful PUT /api/auth/me. The screen itself is the // one exception (so they can actually change it). if (isAuthenticated()) { const u = getCurrentUser(); if (u && u.must_change_password && hash !== '#/change-password') { window.location.hash = '#/change-password'; return; } if (hash === '#/change-password') { if (!u || !u.must_change_password) { // Not (or no longer) required - don't strand the user on a dead screen. window.location.hash = '#/'; return; } sidebar.style.display = 'none'; app.style.marginLeft = '0'; const mb = document.getElementById('mobileMenuBtn'); if (mb) mb.style.display = 'none'; currentView = forcePasswordChange; forcePasswordChange.render(app); return; } } // #12: a signed-in user with zero accessible workspaces (org-less self-signup // on an AUTO_CREATE_ORG_ON_SIGNUP=false deployment) lands on a "no workspaces // yet" empty state instead of being bounced into onboarding (whose pairing // step needs a workspace). Only fires once /me has populated // accessible_workspaces; until then the workspace-empty-safe dashboard shows. if (isAuthenticated()) { const u = getCurrentUser(); if (hasNoAccessibleWorkspace(u) && hash !== '#/no-workspace') { window.location.hash = '#/no-workspace'; return; } if (hash === '#/no-workspace') { if (!hasNoAccessibleWorkspace(u)) { window.location.hash = '#/'; return; } sidebar.style.display = 'none'; app.style.marginLeft = '0'; const mb = document.getElementById('mobileMenuBtn'); if (mb) mb.style.display = 'none'; currentView = noWorkspace; noWorkspace.render(app); return; } } // Onboarding for new users if (hash === '#/onboarding' && isAuthenticated()) { sidebar.style.display = 'none'; app.style.marginLeft = '0'; currentView = onboarding; onboarding.render(app); return; } // Login page (and password-reset links from email) - hide sidebar if (hash === '#/login' || isResetRoute) { sidebar.style.display = 'none'; app.style.marginLeft = '0'; const mb = document.getElementById('mobileMenuBtn'); if (mb) mb.style.display = 'none'; currentView = login; login.render(app); return; } // Show sidebar for authenticated views sidebar.style.display = ''; app.style.marginLeft = ''; const mb = document.getElementById('mobileMenuBtn'); if (mb) mb.style.display = ''; // Update user info in sidebar updateSidebarUser(); const navLinks = document.querySelectorAll('.nav-link'); navLinks.forEach(link => { link.classList.remove('active'); if (hash === '#/' && link.dataset.view === 'dashboard') link.classList.add('active'); else if (hash.startsWith('#/content') && link.dataset.view === 'content') link.classList.add('active'); else if (hash.startsWith('#/settings') && link.dataset.view === 'settings') link.classList.add('active'); else if (hash.startsWith('#/billing') && link.dataset.view === 'billing') link.classList.add('active'); else if ((hash.startsWith('#/layout') || hash === '#/layouts') && link.dataset.view === 'layouts') link.classList.add('active'); else if ((hash === '#/playlists' || hash.startsWith('#/playlists/')) && link.dataset.view === 'playlists') link.classList.add('active'); else if (hash === '#/schedule' && link.dataset.view === 'schedule') link.classList.add('active'); else if (hash === '#/widgets' && link.dataset.view === 'widgets') link.classList.add('active'); else if ((hash.startsWith('#/wall') || hash === '#/walls') && link.dataset.view === 'walls') link.classList.add('active'); else if (hash === '#/reports' && link.dataset.view === 'reports') link.classList.add('active'); else if (hash === '#/activity' && link.dataset.view === 'activity') link.classList.add('active'); else if ((hash === '#/designer' || hash.startsWith('#/designer/')) && link.dataset.view === 'designer') link.classList.add('active'); else if ((hash === '#/kiosk' || hash.startsWith('#/kiosk/')) && link.dataset.view === 'kiosk') link.classList.add('active'); else if (hash === '#/help' && link.dataset.view === 'help') link.classList.add('active'); else if (hash.startsWith('#/device/') && link.dataset.view === 'dashboard') link.classList.add('active'); }); // Route to view if (hash === '#/' || hash === '#' || hash === '') { currentView = dashboard; dashboard.render(app); } else if (hash.startsWith('#/device/')) { const deviceId = hash.split('#/device/')[1].split('/')[0]; currentView = deviceDetail; deviceDetail.render(app, deviceId); } else if (hash === '#/content') { currentView = contentLibrary; contentLibrary.render(app); } else if (hash === '#/playlists' || hash.startsWith('#/playlists/')) { currentView = playlists; playlists.render(app); } else if (hash === '#/layouts' || hash.startsWith('#/layout/')) { currentView = layoutEditor; layoutEditor.render(app); } else if (hash === '#/schedule') { currentView = schedule; schedule.render(app); } else if (hash === '#/widgets') { currentView = widgets; widgets.render(app); } else if (hash === '#/walls' || hash.startsWith('#/wall/')) { currentView = videoWall; videoWall.render(app); } else if (hash === '#/reports') { currentView = reports; reports.render(app); } else if (hash === '#/kiosk' || hash.startsWith('#/kiosk/')) { currentView = kiosk; kiosk.render(app); } else if (hash === '#/designer' || hash.startsWith('#/designer/')) { currentView = designer; // #/designer/ reopens a designer-made widget for editing; #/designer starts fresh. const wid = hash.startsWith('#/designer/') ? hash.split('#/designer/')[1].split('/')[0] : null; designer.render(app, wid || undefined); } else if (hash === '#/activity') { currentView = activity; activity.render(app); } else if (hash === '#/teams' || hash.startsWith('#/team/')) { currentView = teams; teams.render(app); } else if (hash === '#/members') { // The static nav link cannot know the workspace id, so resolve it here from the signed-in // user. Falls back to the first accessible workspace, and to the dashboard when there is // none at all — better than rendering a members page for nothing. // /me is cached in localStorage by refreshCurrentUser(); there is no in-memory copy. let me = null; try { me = JSON.parse(localStorage.getItem('user') || 'null'); } catch (_) { me = null; } const activeWs = me?.current_workspace_id || (Array.isArray(me?.accessible_workspaces) && me.accessible_workspaces[0]?.id); if (!activeWs) { window.location.hash = '#/'; return; } currentView = workspaceMembers; workspaceMembers.render(app, activeWs); } else if (hash.startsWith('#/workspace/') && hash.includes('/members')) { const wsId = hash.split('#/workspace/')[1].split('/')[0]; currentView = workspaceMembers; workspaceMembers.render(app, wsId); } else if (hash === '#/help' || hash.startsWith('#/help')) { currentView = help; help.render(app); } else if (hash.startsWith('#/admin/player-debug')) { // Match prefix so query params (?page=2&ua=Tizen) route correctly. currentView = adminPlayerDebug; adminPlayerDebug.render(app); } else if (hash === '#/admin') { currentView = admin; admin.render(app); } else if (hash === '#/settings') { currentView = settings; settings.render(app); } else if (hash === '#/billing') { // #116: when HIDE_BILLING is set, a direct #/billing navigation is bounced to the // dashboard. replaceState (not a hash assignment) so it doesn't add a history entry // — the back button skips over it instead of looping back into the guard. if (getCurrentUser()?.hide_billing) { history.replaceState(null, '', window.location.pathname + '#/'); currentView = dashboard; dashboard.render(app); } else { currentView = billing; billing.render(app); } } else { currentView = dashboard; dashboard.render(app); } } function updateSidebarUser() { const user = getCurrentUser(); if (!user) return; updateVerifyBanner(user); // Show admin nav only for platform admins (legacy 'superadmin' or Phase 1 renamed 'platform_admin') const adminNav = document.getElementById('adminNavItem'); if (adminNav) adminNav.style.display = isPlatformAdmin(user) ? '' : 'none'; // #116: hide the Subscription nav item when HIDE_BILLING is set (surfaced on /me). // Runs at boot from the cached user (no flash on warm loads) and again after /me. const billingNav = document.getElementById('billingNavItem'); if (billingNav) billingNav.style.display = user.hide_billing ? 'none' : ''; let userEl = document.getElementById('sidebarUser'); if (!userEl) { const footer = document.querySelector('.sidebar-footer'); userEl = document.createElement('div'); userEl.id = 'sidebarUser'; userEl.style.cssText = 'display:flex;align-items:center;gap:8px;margin-bottom:12px;padding-bottom:12px;border-bottom:1px solid var(--border)'; footer.insertBefore(userEl, footer.firstChild); } userEl.innerHTML = ` ${user.avatar_url ? `` : `
${(user.name || user.email)[0].toUpperCase()}
`}
${user.name || user.email}
${user.role}
`; document.getElementById('logoutBtn')?.addEventListener('click', () => { localStorage.removeItem('token'); localStorage.removeItem('user'); window.location.hash = '#/login'; window.location.reload(); }); } // Soft-nudge banner for a logged-in but unverified local user (self-host path — hosted never // issues a session while unverified, so this only appears there). Sits above #app so it persists // across view swaps. Only shown when email_verified is explicitly 0 (undefined on stale caches // stays hidden). Cleared automatically once the account verifies. function updateVerifyBanner(user) { const existing = document.getElementById('verifyBanner'); const unverified = user && user.email_verified === 0 && user.auth_provider === 'local'; if (!unverified) { if (existing) existing.remove(); return; } if (existing) return; const appEl = document.getElementById('app'); if (!appEl || !appEl.parentNode) return; const b = document.createElement('div'); b.id = 'verifyBanner'; b.style.cssText = 'background:var(--warning,#f59e0b);color:#1a1200;padding:9px 16px;font-size:13px;display:flex;align-items:center;justify-content:center;gap:12px;flex-wrap:wrap'; b.innerHTML = `✉️ ${t('auth.verify_banner')}`; const btn = document.createElement('button'); btn.className = 'btn btn-sm'; btn.style.cssText = 'background:#1a1200;color:#fff;padding:4px 12px'; btn.textContent = t('auth.verify_banner_resend'); btn.addEventListener('click', async () => { try { await api.resendVerification(user.email); showToast(t('auth.verify_resent'), 'success'); } catch { showToast(t('auth.verify_resend_failed'), 'error'); } }); b.appendChild(btn); appEl.parentNode.insertBefore(b, appEl); } // Initialize renderNavLabels(); translateStaticDom(); window.addEventListener('language-changed', () => { renderNavLabels(); translateStaticDom(); }); if (isAuthenticated()) { connectSocket(); applyBranding(); refreshCurrentUser().then(() => updateSidebarUser()); } // Refresh the cached user on every route transition so plan/role changes // made by an admin propagate without requiring a re-login. window.addEventListener('hashchange', () => { if (isAuthenticated()) refreshCurrentUser(); }); // Register PWA service worker if ('serviceWorker' in navigator) { navigator.serviceWorker.register('/sw-admin.js').catch(() => {}); } // Mobile sidebar: open/close via hamburger, backdrop, nav tap, Escape const sidebarEl = document.querySelector('.sidebar'); const backdropEl = document.getElementById('sidebarBackdrop'); const menuBtn = document.getElementById('mobileMenuBtn'); function setMobileNav(open) { if (!sidebarEl || !backdropEl) return; sidebarEl.classList.toggle('open', open); backdropEl.classList.toggle('open', open); menuBtn?.setAttribute('aria-expanded', open ? 'true' : 'false'); } menuBtn?.addEventListener('click', () => { setMobileNav(!sidebarEl.classList.contains('open')); }); backdropEl?.addEventListener('click', () => setMobileNav(false)); window.addEventListener('hashchange', () => setMobileNav(false)); window.addEventListener('keydown', (e) => { if (e.key === 'Escape' && sidebarEl?.classList.contains('open')) setMobileNav(false); }); // Auto-reload on frontend update (no more hard refresh needed) let knownHash = null; export function updateVersionIndicator({ version, latest_version, update_available }) { const label = document.getElementById('versionLabel'); const badge = document.getElementById('versionBadge'); if (label) label.textContent = version ? 'v' + version : '-'; if (badge) badge.hidden = !update_available; } // Show loading state while first poll resolves const verLabel = document.getElementById('versionLabel'); if (verLabel) verLabel.textContent = t('common.checking'); async function checkVersion() { try { const res = await fetch('/api/version'); const data = await res.json(); if (knownHash === null) { knownHash = data.hash; } else if (data.hash !== knownHash) { knownHash = data.hash; const toast = document.getElementById('toastContainer'); if (toast) { const notice = document.createElement('div'); notice.className = 'toast info'; const span = document.createElement('span'); span.textContent = 'Dashboard updated. '; const link = document.createElement('a'); link.textContent = 'Reload now'; link.href = '#'; link.style.cssText = 'color:var(--accent);text-decoration:underline;font-weight:600'; // The dashboard CSP is `script-src 'self'` (no 'unsafe-inline'), which blocks // `javascript:` URIs — so the old `href="javascript:location.reload()"` link was dead // (click did nothing, only a CSP console warning). Use a real click listener, which // runs as first-party script and is CSP-clean. link.addEventListener('click', (e) => { e.preventDefault(); location.reload(); }); span.appendChild(link); notice.appendChild(span); toast.appendChild(notice); } } updateVersionIndicator(data); } catch {} } checkVersion(); // Fire first poll immediately setInterval(checkVersion, 15000); // Session timeout warning - check JWT expiry every minute if (isAuthenticated()) { setInterval(() => { const token = localStorage.getItem('token'); if (!token) return; try { const payload = JSON.parse(atob(token.split('.')[1])); const expiresIn = (payload.exp * 1000) - Date.now(); const minutesLeft = Math.floor(expiresIn / 60000); if (minutesLeft <= 0) { localStorage.removeItem('token'); localStorage.removeItem('user'); window.location.hash = '#/login'; window.location.reload(); } else if (minutesLeft <= 30 && minutesLeft % 10 === 0) { // Warn at 30, 20, 10 minutes const toast = document.getElementById('toastContainer'); if (toast && !toast.querySelector('.session-warn')) { const warn = document.createElement('div'); warn.className = 'toast info session-warn'; warn.innerHTML = `Session expires in ${minutesLeft} minutes. Re-login`; toast.appendChild(warn); setTimeout(() => warn.remove(), 10000); } } } catch {} }, 60000); } window.addEventListener('hashchange', route); enableHelpTips(); route(); // Close-modal buttons (replaces inline onclick handlers — required for CSP). document.addEventListener('click', (e) => { const closer = e.target.closest('[data-close-modal]'); if (!closer) return; const id = closer.dataset.closeModal; const modal = document.getElementById(id); if (modal) modal.style.display = 'none'; });