nordabiz/static/js/push-client.js
Maciej Pienczyn 6c4db17807
Some checks are pending
NordaBiz Tests / Unit & Integration Tests (push) Waiting to run
NordaBiz Tests / E2E Tests (Playwright) (push) Blocked by required conditions
NordaBiz Tests / Smoke Tests (Production) (push) Blocked by required conditions
NordaBiz Tests / Send Failure Notification (push) Blocked by required conditions
feat(push): Web Push (VAPID + pywebpush) dla prywatnych wiadomości
Pierwsza iteracja — trigger to nowa wiadomość prywatna. Rollout
fazowany przez PUSH_USER_WHITELIST w .env: pusta = wszyscy, lista
user_id = tylko wymienieni. Ta sama flaga kontroluje widoczność
dzwonka w navbarze (context_processor inject_push_visibility).

Co jest:
- database/migrations/100 — push_subscriptions + notify_push_messages
- database.py — PushSubscription model + relacja na User
- blueprints/push/ — vapid-public-key, subscribe, unsubscribe, test,
  pending-url (iOS PWA), CSRF exempt, auto-prune martwych (410/404/403)
- static/sw.js — push + notificationclick (z iOS fallback przez
  /push/pending-url w Redis, TTL 5 min)
- static/js/push-client.js — togglePush, iOS detection, ?pushdiag=1
- base.html — dzwonek + wpięcie skryptu gated przez push_bell_visible
- message_routes.py — _send_message_push_notifications po emailach
- requirements.txt — pywebpush==2.0.3

Kill switch: PUSH_KILL_SWITCH=1 zatrzymuje wszystkie wysyłki.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 16:56:49 +02:00

196 lines
7.3 KiB
JavaScript

// push-client.js — Web Push subscription flow dla Norda Biznes
//
// Rejestruje subskrypcję pod /push/subscribe, obsługuje dzwonek w navbarze,
// wykrywa iOS PWA, obsługuje diagnostykę ?pushdiag=1, pending-url dla iOS.
(function() {
'use strict';
function log(msg, data) {
try { console.log('[push] ' + msg, data !== undefined ? data : ''); } catch (e) {}
}
function detectIOS() {
return /iPad|iPhone|iPod/.test(navigator.userAgent) && !window.MSStream;
}
function isStandalone() {
return (window.matchMedia && window.matchMedia('(display-mode: standalone)').matches) ||
window.navigator.standalone === true;
}
function supportsPush() {
return ('serviceWorker' in navigator) && ('PushManager' in window) && ('Notification' in window);
}
function urlBase64ToUint8Array(base64String) {
const padding = '='.repeat((4 - base64String.length % 4) % 4);
const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/');
const rawData = atob(base64);
const output = new Uint8Array(rawData.length);
for (let i = 0; i < rawData.length; i++) output[i] = rawData.charCodeAt(i);
return output;
}
function getBell() { return document.getElementById('pushBellBtn'); }
function setBellState(state) {
const bell = getBell();
if (!bell) return;
bell.dataset.state = state;
const slash = bell.querySelector('.push-disabled-slash');
if (state === 'enabled') {
bell.title = 'Powiadomienia włączone — kliknij, żeby wyłączyć';
if (slash) slash.style.display = 'none';
bell.style.opacity = '1';
} else if (state === 'disabled') {
bell.title = 'Włącz powiadomienia';
if (slash) slash.style.display = '';
bell.style.opacity = '0.55';
} else if (state === 'blocked') {
bell.title = 'Powiadomienia zablokowane w przeglądarce. Zmień w ustawieniach strony.';
if (slash) slash.style.display = '';
bell.style.opacity = '0.4';
} else if (state === 'unsupported') {
bell.title = 'Przeglądarka nie obsługuje powiadomień';
bell.style.opacity = '0.3';
}
}
async function getSubscription() {
const reg = await navigator.serviceWorker.ready;
return reg.pushManager.getSubscription();
}
async function enablePush() {
if (detectIOS() && !isStandalone()) {
alert('Na iPhone powiadomienia działają tylko po dodaniu strony do ekranu początkowego:\n\n1) Dotknij Udostępnij (strzałka na dole)\n2) Wybierz „Do ekranu początkowego"\n3) Uruchom aplikację z ikonki na pulpicie\n4) Zaloguj się i kliknij ponownie dzwoneczek');
return;
}
if (!supportsPush()) {
alert('Twoja przeglądarka nie obsługuje powiadomień.');
setBellState('unsupported');
return;
}
const perm = await Notification.requestPermission();
if (perm !== 'granted') {
setBellState(perm === 'denied' ? 'blocked' : 'disabled');
return;
}
try {
const keyResp = await fetch('/push/vapid-public-key', { credentials: 'include' });
if (!keyResp.ok) throw new Error('vapid-public-key HTTP ' + keyResp.status);
const { key } = await keyResp.json();
const reg = await navigator.serviceWorker.ready;
let sub = await reg.pushManager.getSubscription();
if (!sub) {
sub = await reg.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(key),
});
}
const subObj = sub.toJSON();
const resp = await fetch('/push/subscribe', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
endpoint: subObj.endpoint,
keys: subObj.keys,
}),
credentials: 'include',
});
if (!resp.ok) throw new Error('subscribe HTTP ' + resp.status);
setBellState('enabled');
// Welcome push — test że wszystko działa
await fetch('/push/test', { method: 'POST', credentials: 'include' });
} catch (e) {
log('enable error', e);
alert('Nie udało się włączyć powiadomień: ' + (e.message || e));
setBellState('disabled');
}
}
async function disablePush() {
try {
const sub = await getSubscription();
if (sub) {
const endpoint = sub.endpoint;
await sub.unsubscribe();
await fetch('/push/unsubscribe', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ endpoint }),
credentials: 'include',
});
}
setBellState('disabled');
} catch (e) {
log('disable error', e);
}
}
window.togglePush = async function(event) {
if (event) event.preventDefault();
const bell = getBell();
if (!bell) return;
const state = bell.dataset.state || 'disabled';
if (state === 'enabled') {
await disablePush();
} else {
await enablePush();
}
};
async function initPushState() {
const bell = getBell();
if (!bell) return;
if (!supportsPush()) {
// iOS Safari bez PWA — przeglądarka w ogóle bez PushManager.
// Dzwonek nadal klikalny, zobaczy komunikat o PWA.
if (detectIOS() && !isStandalone()) {
setBellState('disabled');
return;
}
setBellState('unsupported');
return;
}
if (Notification.permission === 'denied') {
setBellState('blocked');
return;
}
try {
const sub = await getSubscription();
setBellState(sub && Notification.permission === 'granted' ? 'enabled' : 'disabled');
} catch (e) {
setBellState('disabled');
}
}
async function checkPendingUrl() {
// iOS PWA: jeśli otworzono PWA przez klik w powiadomienie, SW mógł zapisać URL
if (!isStandalone()) return;
try {
const resp = await fetch('/push/pending-url', { credentials: 'include' });
if (!resp.ok) return;
const { url } = await resp.json();
if (url && url !== window.location.pathname + window.location.search) {
window.location.href = url;
}
} catch (e) { /* ignore */ }
}
document.addEventListener('DOMContentLoaded', function() {
initPushState();
checkPendingUrl();
if (/[?&]pushdiag=1/.test(window.location.search)) {
log('diagnostics', {
supportsPush: supportsPush(),
iOS: detectIOS(),
standalone: isStandalone(),
permission: (window.Notification && Notification.permission) || 'n/a',
userAgent: navigator.userAgent,
});
}
});
})();