source: Klonkt/src/views/pages/admin-push.ejs@ 053bf51

main
Last change on this file since 053bf51 was 053bf51, checked in by Robin <roboburr@…>, 7 weeks ago

Feature: web push slice 2, enable/disable UI + service worker delivery

The visible half of docs/webpush-design.md: an owner can now turn on
notifications per device, pick what to be notified about, and send a test.

  • Routes: GET /push/vapid (the public key, public by design), POST /push/subscribe|/unsubscribe|/alerts|/test (logged-in; a subscription row is personal, only its creator may touch it). Mounted before the /:slug catch-all, like /paid.
  • Service worker: push handler (shows the encrypted JSON payload {type,title,body,url}; same-type bursts collapse via tag) and notificationclick (focus an open tab and navigate, else open a window). Cache name bumped to v19.
  • Beheer -> Notificaties (/admin/push): per-device toggle, alert-type checkboxes (saved prefs shown for the current device), test button, linked-devices list with remove, iOS install hint (push needs an installed PWA there), plain <script> so injectCspNonce provides the real nonce.
  • Not premium-gated: notifications are infrastructure, not an extra.

Verified live on a dev server: /push/vapid serves the generated key,
storage/.vapid is 0600, sw.js carries both handlers, and an unauthenticated
subscribe is refused.

Changed files:
src/server.js

  • mount /push + /admin/push; sw.js push/notificationclick handlers, v19

src/views/pages/admin.ejs

  • "Notificaties" button (always visible, not premium)

New file:
src/routes/push.js

  • vapid/subscribe/unsubscribe/alerts/test

src/routes/admin-push.js

  • the Beheer page (requireSiteManager)

src/views/pages/admin-push.ejs

  • device toggle, prefs, test, device list, iOS hint

-robo
Co-Authored-By: Claude Opus 4.8 <noreply@…>

  • Property mode set to 100644
File size: 8.4 KB
Line 
1<div class="container" style="max-width:640px;margin:1.5rem auto 3rem;padding:0 1rem">
2 <h1 style="margin:0 0 .3rem">Notificaties</h1>
3 <p style="color:var(--ink-soft,#888);margin:0 0 1.2rem">
4 Krijg een melding op dit apparaat bij nieuwe volgers, reacties en berichten,
5 ook als de site niet open staat. Versleuteld tot in je browser; wij sturen zo
6 min mogelijk inhoud mee.
7 </p>
8
9 <% if (!vapidKey) { %>
10 <p style="color:#c0392b">Push is op deze server niet beschikbaar (sleutel kon niet worden aangemaakt of de dependency ontbreekt).</p>
11 <% } else { %>
12
13 <div id="np-unsupported" hidden style="color:#c0392b;margin:0 0 1rem">
14 Deze browser ondersteunt geen push-notificaties.
15 </div>
16 <div id="np-ios-hint" hidden style="border:1px solid var(--border,#333);border-radius:10px;padding:.8rem 1rem;margin:0 0 1rem;font-size:.92rem;color:var(--ink-soft,#888)">
17 Op iPhone/iPad werkt dit alleen als de site op je beginscherm staat:
18 deel-knop → <em>Zet op beginscherm</em>, en open 'm daarna vanaf daar.
19 </div>
20
21 <section style="border:1px solid var(--border,#333);border-radius:12px;padding:1rem 1.1rem;margin:0 0 1.2rem">
22 <p style="margin:0 0 .6rem"><strong>Dit apparaat:</strong> <span id="np-state">controleren…</span></p>
23 <div style="display:flex;gap:.6rem;flex-wrap:wrap">
24 <button type="button" id="np-on" class="btn btn-primary" hidden>Zet aan op dit apparaat</button>
25 <button type="button" id="np-off" class="btn" hidden>Zet uit</button>
26 <button type="button" id="np-test" class="btn" hidden>Stuur testmelding</button>
27 </div>
28 <fieldset id="np-alerts" hidden style="border:none;padding:0;margin:1rem 0 0">
29 <legend style="font-weight:600;margin-bottom:.4rem">Waarvoor wil je een melding?</legend>
30 <label style="display:block"><input type="checkbox" data-alert="follow"> Nieuwe volger</label>
31 <label style="display:block"><input type="checkbox" data-alert="reply"> Reactie of vermelding</label>
32 <label style="display:block"><input type="checkbox" data-alert="like"> Waardering (ster)</label>
33 <label style="display:block"><input type="checkbox" data-alert="boost"> Boost</label>
34 <label style="display:block"><input type="checkbox" data-alert="dm"> Priv&eacute;bericht</label>
35 <p id="np-saved" hidden style="color:var(--accent);margin:.4rem 0 0">Opgeslagen.</p>
36 </fieldset>
37 </section>
38
39 <% if (subscriptions.length) { %>
40 <h2 style="font-size:1.05rem;margin:0 0 .5rem">Gekoppelde apparaten</h2>
41 <ul style="list-style:none;padding:0;margin:0 0 1.2rem">
42 <% subscriptions.forEach(function (s) { %>
43 <li data-endpoint="<%= s.endpoint %>" style="display:flex;justify-content:space-between;gap:.8rem;align-items:center;padding:.45rem 0;border-bottom:1px solid var(--border,#2a2a2a)">
44 <span style="min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap"><%= s.ua_label || 'Apparaat' %> <small style="color:var(--ink-soft,#888)">sinds <%= String(s.created_at || '').slice(0, 10) %></small></span>
45 <button type="button" class="btn np-remove" data-endpoint="<%= s.endpoint %>">Verwijder</button>
46 </li>
47 <% }); %>
48 </ul>
49 <% } %>
50
51 <% } %>
52
53 <p style="margin-top:1.5rem"><a href="/admin">&larr; Terug naar Beheer</a></p>
54</div>
55
56<% if (vapidKey) { %>
57<script id="np-data" type="application/json"><%- JSON.stringify({
58 vapid: vapidKey,
59 alerts: defaultAlerts,
60 saved: subscriptions.reduce(function (m, s) { try { m[s.endpoint] = JSON.parse(s.alert_types || '{}'); } catch (e) { m[s.endpoint] = {}; } return m; }, {}),
61}).replace(/</g, '\\u003c') %></script>
62<script>
63(function () {
64 var cfg = JSON.parse(document.getElementById('np-data').textContent);
65 var elState = document.getElementById('np-state');
66 var btnOn = document.getElementById('np-on'), btnOff = document.getElementById('np-off'), btnTest = document.getElementById('np-test');
67 var alertsBox = document.getElementById('np-alerts'), savedMsg = document.getElementById('np-saved');
68 var currentEndpoint = null;
69
70 var isIos = /iPad|iPhone|iPod/.test(navigator.userAgent);
71 var standalone = window.matchMedia('(display-mode: standalone)').matches || window.navigator.standalone === true;
72 if (isIos && !standalone) document.getElementById('np-ios-hint').hidden = false;
73
74 if (!('serviceWorker' in navigator) || !('PushManager' in window) || !('Notification' in window)) {
75 document.getElementById('np-unsupported').hidden = false;
76 elState.textContent = 'niet ondersteund';
77 return;
78 }
79
80 function b64ToU8(s) {
81 var pad = '='.repeat((4 - (s.length % 4)) % 4);
82 var raw = atob((s + pad).replace(/-/g, '+').replace(/_/g, '/'));
83 var out = new Uint8Array(raw.length);
84 for (var i = 0; i < raw.length; i++) out[i] = raw.charCodeAt(i);
85 return out;
86 }
87 function post(url, body) {
88 return fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body || {}) });
89 }
90 function deviceLabel() {
91 var ua = navigator.userAgent;
92 var browser = /Firefox\//.test(ua) ? 'Firefox' : /Edg\//.test(ua) ? 'Edge' : /Chrome\//.test(ua) ? 'Chrome' : /Safari\//.test(ua) ? 'Safari' : 'Browser';
93 var os = /Android/.test(ua) ? 'Android' : /iPad|iPhone|iPod/.test(ua) ? 'iOS' : /Mac/.test(ua) ? 'macOS' : /Win/.test(ua) ? 'Windows' : /Linux/.test(ua) ? 'Linux' : '';
94 return (browser + (os ? ' op ' + os : ''));
95 }
96 function readAlertBoxes() {
97 var out = {};
98 alertsBox.querySelectorAll('input[data-alert]').forEach(function (cb) { out[cb.getAttribute('data-alert')] = cb.checked ? 1 : 0; });
99 return out;
100 }
101 function setAlertBoxes(alerts) {
102 alertsBox.querySelectorAll('input[data-alert]').forEach(function (cb) {
103 cb.checked = !!alerts[cb.getAttribute('data-alert')];
104 });
105 }
106
107 function render(sub) {
108 currentEndpoint = sub ? sub.endpoint : null;
109 elState.textContent = sub ? 'meldingen staan aan' : (Notification.permission === 'denied' ? 'geblokkeerd in de browserinstellingen' : 'meldingen staan uit');
110 btnOn.hidden = !!sub || Notification.permission === 'denied';
111 btnOff.hidden = !sub;
112 btnTest.hidden = !sub;
113 alertsBox.hidden = !sub;
114 }
115
116 navigator.serviceWorker.ready.then(function (reg) {
117 return reg.pushManager.getSubscription();
118 }).then(function (sub) {
119 // Show this device's SAVED prefs when we know them, defaults otherwise.
120 setAlertBoxes((sub && cfg.saved[sub.endpoint]) ? Object.assign({}, cfg.alerts, cfg.saved[sub.endpoint]) : cfg.alerts);
121 render(sub);
122 }).catch(function () { elState.textContent = 'status onbekend'; });
123
124 btnOn.addEventListener('click', function () {
125 btnOn.disabled = true;
126 Notification.requestPermission().then(function (perm) {
127 if (perm !== 'granted') { btnOn.disabled = false; render(null); return; }
128 navigator.serviceWorker.ready.then(function (reg) {
129 return reg.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: b64ToU8(cfg.vapid) });
130 }).then(function (sub) {
131 return post('/push/subscribe', { subscription: sub.toJSON(), alerts: readAlertBoxes(), uaLabel: deviceLabel() })
132 .then(function (r) { if (!r.ok) throw new Error('subscribe failed'); render(sub); location.reload(); });
133 }).catch(function () { btnOn.disabled = false; elState.textContent = 'aanzetten mislukt'; });
134 });
135 });
136
137 btnOff.addEventListener('click', function () {
138 navigator.serviceWorker.ready.then(function (reg) { return reg.pushManager.getSubscription(); }).then(function (sub) {
139 if (!sub) { render(null); return; }
140 var ep = sub.endpoint;
141 sub.unsubscribe().then(function () { return post('/push/unsubscribe', { endpoint: ep }); })
142 .then(function () { location.reload(); });
143 });
144 });
145
146 btnTest.addEventListener('click', function () {
147 btnTest.disabled = true;
148 post('/push/test').then(function () { setTimeout(function () { btnTest.disabled = false; }, 2000); });
149 });
150
151 alertsBox.addEventListener('change', function () {
152 if (!currentEndpoint) return;
153 post('/push/alerts', { endpoint: currentEndpoint, alerts: readAlertBoxes() }).then(function (r) {
154 if (r.ok) { savedMsg.hidden = false; setTimeout(function () { savedMsg.hidden = true; }, 1500); }
155 });
156 });
157
158 document.querySelectorAll('.np-remove').forEach(function (btn) {
159 btn.addEventListener('click', function () {
160 post('/push/unsubscribe', { endpoint: btn.getAttribute('data-endpoint') }).then(function () { location.reload(); });
161 });
162 });
163})();
164</script>
165<% } %>
Note: See TracBrowser for help on using the repository browser.