/* ═══════════════════════════════════════════════════════════ API client + RBAC session management Roles (returned by /login): viewer — read only (AUTH_PASSWORD) operator — fire/transfer/edit (OPERATOR_PASSWORD) admin — full access (ADMIN_PASSWORD) Session: token + role stored in sessionStorage. One /login call per session — no re-entry for the same tab. Admin operations (export/import/reset) reuse the cached token; no separate admin gate needed once logged in as admin. Security fixes: - Tokens are HMAC-SHA256 (server-side secret) — not plain SHA-256 - Error messages never leak server internals - Offline queue + optimistic locking preserved ═══════════════════════════════════════════════════════════ */ (function () { const BOOT = window.__BOOT__ || {}; const trim = (u) => (u || '').replace(/\/+$/, ''); const OFFLINE_KEY = 'arty_offline_queue'; const SESSION_KEY = 'arty_session'; // { token, role } let CONFIG = { apiUrl: trim(BOOT.apiBase) || '', appName: 'ניהול תחמושת סוללתית', appShort: 'ARTY · AMMO', version: '6.5', requireEditAuth: true, roles: { hasOperator: false, hasAdmin: false }, }; window.APP_CONFIG = CONFIG; async function loadConfig() { const base = trim(BOOT.apiBase); if (base) { try { const ctl = new AbortController(); const timer = setTimeout(() => ctl.abort(), 3500); const r = await fetch(base + '/config', { cache: 'no-store', signal: ctl.signal }); clearTimeout(timer); if (r.ok) { const d = await r.json(); CONFIG = { ...CONFIG, ...d, apiUrl: trim(d.apiUrl) || base }; } } catch {} } window.APP_CONFIG = CONFIG; return CONFIG; } const api = { _token: null, _role: null, // 'viewer' | 'operator' | 'admin' _version: 0, _saving: false, _pendingSave: null, _pollTimer: null, _online: navigator.onLine, _offlineQueue: null, base() { return (window.APP_CONFIG?.apiUrl) || trim(BOOT.apiBase) || ''; }, // ── Role helpers ───────────────────────────────────────── getRole() { return api._role; }, isViewer() { return api._role === 'viewer'; }, isOperator() { return api._role === 'operator' || api._role === 'admin'; }, isAdmin() { return api._role === 'admin'; }, canEdit() { return api.isOperator(); }, canAdminActions() { return api.isAdmin(); }, // ── Online / offline detection ─────────────────────────── isOnline() { return api._online; }, hasOfflineQueue() { if (api._offlineQueue) return true; try { return !!localStorage.getItem(OFFLINE_KEY); } catch { return false; } }, setOnlineStatus(online) { const changed = api._online !== online; api._online = online; if (changed) { window.dispatchEvent(new CustomEvent('arty:online-status', { detail: { online } })); if (online) { if (api._pollTimer !== null) api.startPolling(30000); if (api.hasOfflineQueue()) window.dispatchEvent(new CustomEvent('arty:back-online')); } else { if (api._pollTimer) clearInterval(api._pollTimer); } } }, // ── Auth ───────────────────────────────────────────────── // Single login call — server returns token + role. // Token and role are cached in sessionStorage for the session. async login(password) { const r = await fetch(this.base() + '/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ password }), }); if (!r.ok) throw new Error('wrong_password'); const d = await r.json(); api._token = d.token; api._role = d.role; try { sessionStorage.setItem(SESSION_KEY, JSON.stringify({ token: d.token, role: d.role })); } catch {} return d; }, restoreSession() { try { const raw = sessionStorage.getItem(SESSION_KEY); if (raw) { const s = JSON.parse(raw); if (s.token && s.role) { api._token = s.token; api._role = s.role; return s; } } } catch {} return null; }, logout() { api._token = null; api._role = null; api._version = 0; api._offlineQueue = null; try { sessionStorage.removeItem(SESSION_KEY); } catch {} try { localStorage.removeItem(OFFLINE_KEY); } catch {} }, // ── State ──────────────────────────────────────────────── async loadState() { const r = await fetch(this.base() + '/state', { headers: { 'Authorization': 'Bearer ' + api._token }, }); if (r.status === 401) { api.logout(); throw new Error('auth'); } const d = await r.json(); if (typeof d.version === 'number') api._version = d.version; // Server echoes back the confirmed role — keep in sync if (d.role && d.role !== api._role) { api._role = d.role; try { const raw = sessionStorage.getItem(SESSION_KEY); if (raw) { const s = JSON.parse(raw); sessionStorage.setItem(SESSION_KEY, JSON.stringify({ ...s, role: d.role })); } } catch {} } return d; }, // ── Offline sync queue ─────────────────────────────────── async syncOfflineQueue() { let pendingState = api._offlineQueue; if (!pendingState) { try { const raw = localStorage.getItem(OFFLINE_KEY); if (raw) pendingState = JSON.parse(raw); } catch {} } if (!pendingState) return { synced: false, reason: 'nothing_queued' }; if (!api._online) return { synced: false, reason: 'still_offline' }; api._offlineQueue = null; try { localStorage.removeItem(OFFLINE_KEY); } catch {} try { const r = await fetch(api.base() + '/state', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + api._token }, body: JSON.stringify({ data: pendingState, version: api._version }), }); if (r.status === 409) { const conflict = await r.json(); if (typeof conflict.version === 'number') api._version = conflict.version; if (conflict.data && window.__appDispatch) { const { applyMigrations } = window; window.__appDispatch({ type: 'IMPORT_STATE', payload: applyMigrations ? applyMigrations(conflict.data) : conflict.data }); } window.dispatchEvent(new CustomEvent('arty:sync-conflict')); return { synced: false, reason: 'conflict_server_wins' }; } if (r.ok) { api._version += 1; return { synced: true }; } api._offlineQueue = pendingState; try { localStorage.setItem(OFFLINE_KEY, JSON.stringify(pendingState)); } catch {} return { synced: false, reason: 'server_error' }; } catch (e) { api._offlineQueue = pendingState; try { localStorage.setItem(OFFLINE_KEY, JSON.stringify(pendingState)); } catch {} return { synced: false, reason: 'network_error' }; } }, // ── Save ───────────────────────────────────────────────── async saveState(state) { if (!api._token) return; if (!api._online) { api._offlineQueue = state; try { localStorage.setItem(OFFLINE_KEY, JSON.stringify(state)); } catch {} window.dispatchEvent(new CustomEvent('arty:queued-offline')); return; } if (api._saving) { api._pendingSave = state; return; } api._saving = true; try { const r = await fetch(this.base() + '/state', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + api._token }, body: JSON.stringify({ data: state, version: api._version }), }); if (r.status === 409) { const conflict = await r.json(); if (typeof conflict.version === 'number') api._version = conflict.version; if (conflict.data && window.__appDispatch) { const { applyMigrations } = window; window.__appDispatch({ type: 'IMPORT_STATE', payload: applyMigrations ? applyMigrations(conflict.data) : conflict.data }); } console.warn('[arty] Sync conflict — UI refreshed from server'); if (window.__syncConflictToast) window.__syncConflictToast(); } else if (r.ok) { api._version += 1; api._offlineQueue = null; try { localStorage.removeItem(OFFLINE_KEY); } catch {} } else { api._offlineQueue = state; try { localStorage.setItem(OFFLINE_KEY, JSON.stringify(state)); } catch {} window.dispatchEvent(new CustomEvent('arty:queued-offline')); } } catch (e) { console.warn('[arty] saveState error — queuing offline:', e.message); api._online = false; api._offlineQueue = state; try { localStorage.setItem(OFFLINE_KEY, JSON.stringify(state)); } catch {} window.dispatchEvent(new CustomEvent('arty:online-status', { detail: { online: false } })); window.dispatchEvent(new CustomEvent('arty:queued-offline')); } finally { api._saving = false; if (api._pendingSave) { const next = api._pendingSave; api._pendingSave = null; await api.saveState(next); } } }, // ── Polling ────────────────────────────────────────────── startPolling(intervalMs = 30000) { if (api._pollTimer) clearInterval(api._pollTimer); api._pollTimer = setInterval(async () => { if (!api._token || api._saving || !api._online) return; try { const r = await fetch(api.base() + '/state', { headers: { 'Authorization': 'Bearer ' + api._token }, cache: 'no-store', }); // 401 = token expired (midnight rollover) — notify user if (r.status === 401) { api.stopPolling(); window.dispatchEvent(new CustomEvent('arty:session-expired')); return; } if (!r.ok) return; const d = await r.json(); if (typeof d.version === 'number' && d.version > api._version) { api._version = d.version; if (d.data && window.__appDispatch) { const { applyMigrations } = window; window.__appDispatch({ type: 'IMPORT_STATE', payload: applyMigrations ? applyMigrations(d.data) : d.data }); } } } catch {} }, intervalMs); }, stopPolling() { if (api._pollTimer) { clearInterval(api._pollTimer); api._pollTimer = null; } }, // ── Export / Import (admin only — uses cached token) ───── async exportState() { const r = await fetch(this.base() + '/export', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + api._token }, body: '{}', }); if (r.status === 403) throw new Error('not_admin'); if (r.status === 401) throw new Error('auth'); if (!r.ok) throw new Error('export_failed'); return await r.json(); }, async importState(stateData, version) { const r = await fetch(this.base() + '/import', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + api._token }, body: JSON.stringify({ data: stateData, version: version || 0 }), }); if (r.status === 403) throw new Error('not_admin'); if (r.status === 401) throw new Error('auth'); if (!r.ok) throw new Error('import_failed'); api._version = (version || 0) + 1; return await r.json(); }, }; window.addEventListener('online', () => api.setOnlineStatus(true)); window.addEventListener('offline', () => api.setOnlineStatus(false)); window.api = api; window.loadConfig = loadConfig; })();