'use strict'; // ========== Storage abstraction (decisions #1024, #1025) ========== // // #1024: probe availability with a real write, NOT typeof/in // #1025: fallback chain: localStorage → sessionStorage → in-memory let _storage = null; let _storageType = 'memory'; const _mem = {}; function _probeStorage(s) { try { const k = '__baton_probe__'; s.setItem(k, '1'); s.removeItem(k); return true; } catch (_) { return false; } } function _initStorage() { if (_probeStorage(localStorage)) { _storage = localStorage; _storageType = 'local'; } else if (_probeStorage(sessionStorage)) { _storage = sessionStorage; _storageType = 'session'; } else { _storage = { getItem: (k) => (Object.prototype.hasOwnProperty.call(_mem, k) ? _mem[k] : null), setItem: (k, v) => { _mem[k] = String(v); }, removeItem: (k) => { delete _mem[k]; }, }; _storageType = 'memory'; } } // ========== User identity ========== function _isRegistered() { return !!_storage.getItem('baton_auth_token'); } function _getUserName() { return _storage.getItem('baton_login') || ''; } function _getAuthToken() { return _storage.getItem('baton_auth_token') || ''; } function _saveAuth(token, login) { _storage.setItem('baton_auth_token', token); _storage.setItem('baton_login', login); } function _clearAuth() { _storage.removeItem('baton_auth_token'); _storage.removeItem('baton_login'); } function _getInitials(name) { const trimmed = (name || '').trim(); if (!trimmed) return '?'; return trimmed .split(/\s+/) .map((w) => w[0] || '') .join('') .toUpperCase() .slice(0, 2); } // ========== UI helpers ========== function _showScreen(id) { document.querySelectorAll('.screen').forEach((el) => { el.hidden = el.id !== id; }); } function _setStatus(msg, cls) { const el = document.getElementById('status'); el.textContent = msg; el.className = 'status' + (cls ? ' status--' + cls : ''); el.hidden = !msg; } function _setRegStatus(msg, cls) { const el = document.getElementById('reg-status'); if (!el) return; el.textContent = msg; el.className = 'reg-status' + (cls ? ' reg-status--' + cls : ''); el.hidden = !msg; } function _setLoginStatus(msg, cls) { const el = document.getElementById('login-status'); if (!el) return; el.textContent = msg; el.className = 'reg-status' + (cls ? ' reg-status--' + cls : ''); el.hidden = !msg; } function _showView(id) { ['view-login', 'view-register'].forEach((vid) => { const el = document.getElementById(vid); if (el) el.hidden = vid !== id; }); } function _updateNetworkIndicator() { const el = document.getElementById('indicator-network'); if (!el) return; el.className = 'network-indicator ' + (navigator.onLine ? 'online' : 'offline'); el.setAttribute('aria-label', navigator.onLine ? 'Online' : 'Offline'); } function _updateUserAvatar() { const el = document.getElementById('user-avatar'); if (!el) return; el.textContent = _getInitials(_getUserName()); } // ========== API calls ========== async function _apiPost(path, body, extraHeaders) { const res = await fetch(path, { method: 'POST', headers: { 'Content-Type': 'application/json', ...extraHeaders }, body: JSON.stringify(body), }); if (!res.ok) { const text = await res.text().catch(() => ''); const err = new Error('HTTP ' + res.status + (text ? ': ' + text : '')); err.status = res.status; throw err; } return res.json(); } // ========== Geolocation (optional, non-blocking) ========== function _getGeo() { return new Promise((resolve) => { if (!navigator.geolocation) { resolve(null); return; } navigator.geolocation.getCurrentPosition( ({ coords }) => resolve({ lat: coords.latitude, lon: coords.longitude, accuracy: coords.accuracy, }), () => resolve(null), { timeout: 5000, maximumAge: 30000 } ); }); } // ========== Handlers ========== async function _handleLogin() { const loginInput = document.getElementById('login-input'); const passInput = document.getElementById('login-password'); const btn = document.getElementById('btn-login'); const login = loginInput.value.trim(); const password = passInput.value; if (!login || !password) return; btn.disabled = true; _setLoginStatus('', ''); try { const data = await _apiPost('/api/auth/login', { login_or_email: login, password: password, }); _saveAuth(data.token, data.login); passInput.value = ''; _updateUserAvatar(); _showMain(); } catch (err) { let msg = 'Ошибка входа. Попробуйте ещё раз.'; if (err && err.message) { const colonIdx = err.message.indexOf(': '); if (colonIdx !== -1) { try { const parsed = JSON.parse(err.message.slice(colonIdx + 2)); if (parsed.detail) msg = parsed.detail; } catch (_) {} } } _setLoginStatus(msg, 'error'); btn.disabled = false; } } function _setSosState(state) { const btn = document.getElementById('btn-sos'); if (!btn) return; btn.dataset.state = state; btn.disabled = state === 'sending'; } async function _handleTestSignal() { if (!navigator.onLine) { _setStatus('Нет соединения.', 'error'); return; } const token = _getAuthToken(); if (!token) return; _setStatus('', ''); try { const geo = await _getGeo(); const body = { timestamp: Date.now(), is_test: true }; if (geo) body.geo = geo; await _apiPost('/api/signal', body, { Authorization: 'Bearer ' + token }); _setStatus('Тест отправлен', 'success'); setTimeout(() => _setStatus('', ''), 1500); } catch (err) { if (err && err.status === 401) { _clearAuth(); _setStatus('Сессия истекла. Войдите заново.', 'error'); setTimeout(() => _showOnboarding(), 1500); } else { _setStatus('Ошибка отправки.', 'error'); } } } async function _handleSignal() { if (!navigator.onLine) { _setStatus('Нет соединения. Проверьте сеть и попробуйте снова.', 'error'); return; } const token = _getAuthToken(); if (!token) { _clearAuth(); _showOnboarding(); return; } _setSosState('sending'); _setStatus('', ''); try { const geo = await _getGeo(); const body = { timestamp: Date.now() }; if (geo) body.geo = geo; await _apiPost('/api/signal', body, { Authorization: 'Bearer ' + token }); _setSosState('success'); _setStatus('Сигнал отправлен!', 'success'); setTimeout(() => { _setSosState('default'); _setStatus('', ''); }, 2000); } catch (err) { _setSosState('default'); if (err && err.status === 401) { _clearAuth(); _setStatus('Сессия истекла. Войдите заново.', 'error'); setTimeout(() => _showOnboarding(), 1500); } else { _setStatus('Ошибка отправки. Попробуйте ещё раз.', 'error'); } } } // ========== Screens ========== function _showOnboarding() { _showScreen('screen-onboarding'); _showView('view-login'); const loginInput = document.getElementById('login-input'); const passInput = document.getElementById('login-password'); const btnLogin = document.getElementById('btn-login'); function _updateLoginBtn() { btnLogin.disabled = !loginInput.value.trim() || !passInput.value; } loginInput.addEventListener('input', _updateLoginBtn); passInput.addEventListener('input', _updateLoginBtn); passInput.addEventListener('keydown', (e) => { if (e.key === 'Enter' && !btnLogin.disabled) _handleLogin(); }); btnLogin.addEventListener('click', _handleLogin); const btnToRegister = document.getElementById('btn-switch-to-register'); if (btnToRegister) { btnToRegister.addEventListener('click', () => { _setRegStatus('', ''); _setLoginStatus('', ''); _showView('view-register'); }); } const btnToLogin = document.getElementById('btn-switch-to-login'); if (btnToLogin) { btnToLogin.addEventListener('click', () => { _setLoginStatus('', ''); _showView('view-login'); }); } const btnRegister = document.getElementById('btn-register'); if (btnRegister) { btnRegister.addEventListener('click', _handleSignUp); } } function _showMain() { _showScreen('screen-main'); _updateUserAvatar(); const btn = document.getElementById('btn-sos'); if (btn && !btn.dataset.listenerAttached) { btn.addEventListener('click', _handleSignal); btn.dataset.listenerAttached = '1'; } // Avatar and network indicator → test signal (only on main screen) const avatar = document.getElementById('user-avatar'); if (avatar && !avatar.dataset.testAttached) { avatar.addEventListener('click', _handleTestSignal); avatar.dataset.testAttached = '1'; avatar.style.cursor = 'pointer'; } const indicator = document.getElementById('indicator-network'); if (indicator && !indicator.dataset.testAttached) { indicator.addEventListener('click', _handleTestSignal); indicator.dataset.testAttached = '1'; indicator.style.cursor = 'pointer'; } } // ========== Service Worker ========== function _registerSW() { if (!('serviceWorker' in navigator)) return; navigator.serviceWorker.register('/sw.js').catch((err) => { console.warn('[baton] SW registration failed:', err); }); } // ========== VAPID / Push subscription ========== async function _fetchVapidPublicKey() { try { const res = await fetch('/api/push/public-key'); if (!res.ok) { console.warn('[baton] /api/push/public-key returned', res.status); return null; } const data = await res.json(); return data.vapid_public_key || null; } catch (err) { console.warn('[baton] Failed to fetch VAPID public key:', err); return null; } } function _urlBase64ToUint8Array(base64String) { const padding = '='.repeat((4 - (base64String.length % 4)) % 4); const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/'); const raw = atob(base64); const output = new Uint8Array(raw.length); for (let i = 0; i < raw.length; i++) { output[i] = raw.charCodeAt(i); } return output; } async function _initPushSubscription(vapidPublicKey) { if (!vapidPublicKey) { console.warn('[baton] VAPID public key not available — push subscription skipped'); return; } if (!('serviceWorker' in navigator) || !('PushManager' in window)) { return; } try { const registration = await navigator.serviceWorker.ready; const existing = await registration.pushManager.getSubscription(); if (existing) return; const applicationServerKey = _urlBase64ToUint8Array(vapidPublicKey); const subscription = await registration.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey, }); _storage.setItem('baton_push_subscription', JSON.stringify(subscription)); console.info('[baton] Push subscription created'); } catch (err) { console.warn('[baton] Push subscription failed:', err); } } // ========== Registration (account sign-up) ========== async function _getPushSubscriptionForReg() { if (!('serviceWorker' in navigator) || !('PushManager' in window)) return null; try { const vapidKey = await _fetchVapidPublicKey(); if (!vapidKey) return null; const registration = await navigator.serviceWorker.ready; const existing = await registration.pushManager.getSubscription(); if (existing) return existing.toJSON(); const applicationServerKey = _urlBase64ToUint8Array(vapidKey); const subscription = await registration.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey, }); return subscription.toJSON(); } catch (err) { console.warn('[baton] Push subscription for registration failed:', err); return null; } } async function _handleSignUp() { const emailInput = document.getElementById('reg-email'); const loginInput = document.getElementById('reg-login'); const passwordInput = document.getElementById('reg-password'); const btn = document.getElementById('btn-register'); if (!emailInput || !loginInput || !passwordInput || !btn) return; const email = emailInput.value.trim(); const login = loginInput.value.trim(); const password = passwordInput.value; if (!email || !login || !password) { _setRegStatus('Заполните все поля.', 'error'); return; } if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) { _setRegStatus('Введите корректный email.', 'error'); return; } btn.disabled = true; const originalText = btn.textContent.trim(); btn.textContent = '...'; _setRegStatus('', ''); try { const push_subscription = await _getPushSubscriptionForReg().catch(() => null); await _apiPost('/api/auth/register', { email, login, password, push_subscription }); passwordInput.value = ''; _setRegStatus('Заявка отправлена. Ожидайте подтверждения администратора.', 'success'); } catch (err) { let msg = 'Ошибка. Попробуйте ещё раз.'; if (err && err.message) { const colonIdx = err.message.indexOf(': '); if (colonIdx !== -1) { try { const parsed = JSON.parse(err.message.slice(colonIdx + 2)); if (parsed.detail) msg = parsed.detail; } catch (_) {} } } if (err && err.status === 403 && msg !== 'Ошибка. Попробуйте ещё раз.') { _showBlockScreen(msg); } else { _setRegStatus(msg, 'error'); btn.disabled = false; btn.textContent = originalText; } } } function _showBlockScreen(msg) { const screen = document.getElementById('screen-onboarding'); if (!screen) return; screen.innerHTML = '
' + '

' + msg + '

' + '' + '
'; document.getElementById('btn-block-ok').addEventListener('click', () => { location.reload(); }); } // ========== Init ========== function _init() { _initStorage(); // Private mode graceful degradation (decision #1041) if (_storageType !== 'local') { const banner = document.getElementById('private-mode-banner'); if (banner) banner.hidden = false; } // Network indicator — initial state + live updates _updateNetworkIndicator(); window.addEventListener('online', _updateNetworkIndicator); window.addEventListener('offline', _updateNetworkIndicator); // Route to correct screen based on JWT token presence if (_isRegistered()) { _showMain(); } else { _showOnboarding(); } // Fire-and-forget: fetch VAPID key from API and subscribe to push (non-blocking) _fetchVapidPublicKey().then(_initPushSubscription).catch((err) => { console.warn('[baton] Push init error:', err); }); } document.addEventListener('DOMContentLoaded', () => { _registerSW(); _init(); });