/** * push.js — Push Notifications client for ObsiGate * ROADMAP #67 * * Handles: * - Subscribing to Web Push via VAPID * - Unsubscribing * - UI rendering in the config modal */ import { t } from './i18n.js'; let _vapidPublicKey = null; // ── Helpers ────────────────────────────────────────────────────────────────── function _api(method, path, body) { const opts = { method, headers: { 'Content-Type': 'application/json' }, credentials: 'same-origin', }; if (body !== undefined) opts.body = JSON.stringify(body); return fetch(path, opts).then((r) => { if (!r.ok) throw new Error(`${r.status} ${r.statusText}`); return r.json(); }); } function _urlBase64ToUint8Array(base64String) { const padding = '='.repeat((4 - (base64String.length % 4)) % 4); const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/'); const rawData = atob(base64); const outputArray = new Uint8Array(rawData.length); for (let i = 0; i < rawData.length; ++i) { outputArray[i] = rawData.charCodeAt(i); } return outputArray; } // ── VAPID key ──────────────────────────────────────────────────────────────── async function getVapidPublicKey() { if (_vapidPublicKey) return _vapidPublicKey; const data = await _api('GET', '/api/push/vapid-public-key'); _vapidPublicKey = data.public_key; return _vapidPublicKey; } // ── Subscription management ────────────────────────────────────────────────── async function subscribePush(vault) { if (!('serviceWorker' in navigator) || !('PushManager' in window)) { throw new Error('Push notifications not supported by this browser'); } const reg = await navigator.serviceWorker.ready; const vapidKey = await getVapidPublicKey(); // Check existing subscription let subscription = await reg.pushManager.getSubscription(); if (!subscription) { // Subscribe to push subscription = await reg.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: _urlBase64ToUint8Array(vapidKey), }); } // Register subscription with backend const subJSON = subscription.toJSON(); await _api('POST', '/api/push/subscribe', { subscription: { endpoint: subJSON.endpoint, keys: subJSON.keys, }, vault, }); return { success: true }; } async function unsubscribePush() { if (!('serviceWorker' in navigator)) return; const reg = await navigator.serviceWorker.ready; const subscription = await reg.pushManager.getSubscription(); if (!subscription) return; // Unsubscribe from browser await subscription.unsubscribe(); // Unsubscribe from backend const endpoint = subscription.endpoint; try { await _api('DELETE', `/api/push/subscribe?endpoint=${encodeURIComponent(endpoint)}`); } catch { // Best-effort: endpoint may already be expired } } async function isSubscribed() { if (!('serviceWorker' in navigator) || !('PushManager' in window)) return false; const reg = await navigator.serviceWorker.ready; const sub = await reg.pushManager.getSubscription(); return !!sub; } async function getSubscriptions() { return _api('GET', '/api/push/subscriptions'); } // ── Config UI ──────────────────────────────────────────────────────────────── export async function renderPushSection() { const container = document.getElementById('push-settings'); if (!container) return; const supported = 'serviceWorker' in navigator && 'PushManager' in window; const subscribed = await isSubscribed(); if (!supported) { container.innerHTML = `