/** * 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 = `
Notifications push Non supportées par ce navigateur
`; return; } container.innerHTML = `
Notifications push ${subscribed ? 'Activées' : 'Désactivées'}
`; // Populate vault select const vaultSelect = document.getElementById('push-vault-select'); try { const vaultData = await _api('GET', '/api/vaults'); const vaults = vaultData.vaults || vaultData || []; (Array.isArray(vaults) ? vaults : []).forEach((v) => { const name = typeof v === 'string' ? v : v.name; const opt = document.createElement('option'); opt.value = name; opt.textContent = name; vaultSelect.appendChild(opt); }); } catch { const opt = document.createElement('option'); opt.value = 'default'; opt.textContent = 'default'; vaultSelect.appendChild(opt); } // Toggle button document.getElementById('push-toggle-btn')?.addEventListener('click', async () => { const btn = document.getElementById('push-toggle-btn'); const badge = document.getElementById('push-status-badge'); try { btn.disabled = true; btn.textContent = '…'; if (subscribed) { await unsubscribePush(); badge.textContent = 'Désactivées'; badge.className = 'mfa-badge mfa-badge-off'; btn.textContent = 'Activer les notifications'; btn.style.background = ''; btn.style.color = ''; btn.style.borderColor = ''; } else { const vault = vaultSelect.value || 'default'; await subscribePush(vault); badge.textContent = 'Activées'; badge.className = 'mfa-badge mfa-badge-on'; btn.textContent = 'Désactiver les notifications'; btn.style.background = 'var(--danger-bg)'; btn.style.color = 'var(--danger)'; btn.style.borderColor = 'var(--danger)'; } } catch (err) { console.error('[Push] Toggle error:', err); alert('Erreur: ' + err.message); } finally { btn.disabled = false; } }); // Show subscription count try { const data = await getSubscriptions(); const count = (data.subscriptions || []).length; document.getElementById('push-sub-count').textContent = count > 0 ? `${count} abonnement(s) actif(s)` : ''; } catch { // Ignore } } // ── Init ───────────────────────────────────────────────────────────────────── export function initPush() { // Render push section when config modal opens document.addEventListener('DOMContentLoaded', () => { // Observe the config modal becoming visible const observer = new MutationObserver(() => { const section = document.getElementById('cfg-push'); if (section && section.offsetParent !== null) { renderPushSection(); } }); const modal = document.getElementById('config-modal'); if (modal) observer.observe(modal, { attributes: true, attributeFilter: ['class'] }); }); } export default { initPush, subscribePush, unsubscribePush, isSubscribed, renderPushSection };