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
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>
196 lines
7.3 KiB
JavaScript
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,
|
|
});
|
|
}
|
|
});
|
|
})();
|