Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesPull RequestsActionsSecurityInsightsSettings
✨ AI
More
Blame · Line-by-line history

push-notifications.tsx

Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.

push-notifications.tsxBlame675 lines · 2 contributors
a2b3e99Claude1/**
2 * Block M2 addendum — Browser push notification management.
3 *
4 * Routes:
5 * GET /settings/notifications/push — UI page (enable / manage subscriptions)
6 * POST /api/push/subscribe — save a PushSubscription (requireAuth)
7 * POST /api/push/unsubscribe — remove a subscription (requireAuth)
8 * POST /api/push/test — send a test notification (requireAuth)
9 *
10 * VAPID: handled by src/lib/push.ts (pure Web Crypto, no npm dep required).
11 * All four keys land in process.env.VAPID_PUBLIC_KEY / VAPID_PRIVATE_KEY.
12 * When the keys are missing the lib falls back to a process-stable generated
13 * keypair with a console.warn — subscriptions work but break on restart.
14 *
15 * The subscription CRUD is delegated to src/lib/push-notify.ts which wraps
16 * src/lib/push.ts and exposes the inline-defined pushSubscriptions table.
17 *
18 * CSS: every class is prefixed `.pn-*` to avoid collisions with existing
19 * surfaces. No existing file is modified.
20 */
21
22import { Hono } from "hono";
23import { Layout } from "../views/layout";
24import { softAuth, requireAuth } from "../middleware/auth";
25import type { AuthEnv } from "../middleware/auth";
26import {
27 savePushSubscription,
28 deletePushSubscription,
29 listPushSubscriptions,
30 sendPushNotification,
31 type PushSubscriptionRow,
32} from "../lib/push-notify";
33import { getVapidPublicKey } from "../lib/push";
34
35const pushNotifRoutes = new Hono<AuthEnv>();
36
37// ---------------------------------------------------------------------------
38// Middleware — scoped; never "*"
39// ---------------------------------------------------------------------------
40
03e6f9bccantynz-alt41// "path*" (no slash before the *) doesn't match the bare path in this Hono
42// version -- see admin-security.tsx's fix for the confirmed repro.
43pushNotifRoutes.use("/settings/notifications/push", softAuth);
44pushNotifRoutes.use("/settings/notifications/push/*", softAuth);
a2b3e99Claude45pushNotifRoutes.use("/api/push/*", requireAuth);
46
47// ---------------------------------------------------------------------------
48// Inline CSS — .pn-* namespace
49// ---------------------------------------------------------------------------
50
51const PN_STYLES = `
52/* ── push-notifications page ── */
53.pn-wrap {
54 max-width: 760px;
55 margin: 0 auto;
56 padding: var(--space-5) var(--space-4);
57}
58
59/* Hero / breadcrumb */
60.pn-hero {
61 margin-bottom: var(--space-6);
62}
63.pn-crumbs {
64 display: flex;
65 align-items: center;
66 gap: 6px;
67 font-size: 13px;
68 color: var(--text-muted);
69 margin-bottom: var(--space-3);
70}
71.pn-crumbs a { color: var(--text-muted); text-decoration: none; }
72.pn-crumbs a:hover { color: var(--text); text-decoration: underline; }
73.pn-title {
74 font-size: 22px;
75 font-weight: 700;
76 margin: 0 0 6px;
77}
78.pn-sub {
79 font-size: 14px;
80 color: var(--text-muted);
81 margin: 0;
82 line-height: 1.55;
83}
84
85/* Status banner */
86.pn-banner {
87 display: flex;
88 align-items: center;
89 gap: 8px;
90 padding: 10px 14px;
91 border-radius: 8px;
92 font-size: 13px;
93 margin-bottom: var(--space-4);
94 background: rgba(63,185,80,0.08);
95 border: 1px solid rgba(63,185,80,0.25);
96 color: var(--text);
97}
98.pn-banner.is-error {
99 background: rgba(248,81,73,0.08);
100 border-color: rgba(248,81,73,0.25);
101}
102.pn-banner-dot {
103 width: 7px; height: 7px; border-radius: 50%;
104 background: #3fb950;
105 flex-shrink: 0;
106}
107.pn-banner.is-error .pn-banner-dot { background: #f85149; }
108
109/* Card */
110.pn-card {
111 background: var(--bg-elevated);
112 border: 1px solid var(--border);
113 border-radius: 12px;
114 padding: var(--space-5);
115 margin-bottom: var(--space-4);
116}
117.pn-card-title {
118 font-size: 15px;
119 font-weight: 600;
120 margin: 0 0 4px;
121}
122.pn-card-sub {
123 font-size: 13px;
124 color: var(--text-muted);
125 margin: 0 0 var(--space-4);
126 line-height: 1.5;
127}
128
129/* Enable button */
130.pn-enable-btn {
131 display: inline-flex;
132 align-items: center;
133 gap: 7px;
134 padding: 8px 16px;
135 background: var(--accent);
136 color: #fff;
137 border: none;
138 border-radius: 6px;
139 font-size: 13px;
140 font-weight: 600;
141 cursor: pointer;
142 transition: background 0.15s;
143}
144.pn-enable-btn:hover { background: var(--accent-hover); }
145.pn-enable-btn:disabled { opacity: 0.55; cursor: default; }
146
147.pn-secondary-btn {
148 display: inline-flex;
149 align-items: center;
150 gap: 7px;
151 padding: 7px 13px;
152 background: transparent;
153 color: var(--text-muted);
154 border: 1px solid var(--border);
155 border-radius: 6px;
156 font-size: 13px;
157 cursor: pointer;
158 transition: border-color 0.15s, color 0.15s;
159}
160.pn-secondary-btn:hover { color: var(--text); border-color: var(--text-muted); }
161
162/* Subscription list */
163.pn-sub-list {
164 list-style: none;
165 margin: 0;
166 padding: 0;
167 display: flex;
168 flex-direction: column;
169 gap: 10px;
170}
171.pn-sub-item {
172 display: flex;
173 align-items: center;
174 justify-content: space-between;
175 gap: 12px;
176 padding: 10px 14px;
177 background: var(--bg);
178 border: 1px solid var(--border);
179 border-radius: 8px;
180 font-size: 13px;
181}
182.pn-sub-meta {
183 min-width: 0;
184}
185.pn-sub-endpoint {
186 color: var(--text-muted);
187 font-size: 11px;
188 white-space: nowrap;
189 overflow: hidden;
190 text-overflow: ellipsis;
191 max-width: 420px;
192}
193.pn-sub-date {
194 color: var(--text-muted);
195 font-size: 11px;
196 margin-top: 2px;
197}
198.pn-remove-btn {
199 flex-shrink: 0;
200 padding: 4px 10px;
201 background: transparent;
202 color: #f85149;
203 border: 1px solid rgba(248,81,73,0.35);
204 border-radius: 5px;
205 font-size: 12px;
206 cursor: pointer;
207 transition: background 0.15s;
208}
209.pn-remove-btn:hover { background: rgba(248,81,73,0.08); }
210
211/* Events grid */
212.pn-events-grid {
213 display: grid;
214 gap: 10px;
215}
216.pn-event-row {
217 display: flex;
218 align-items: flex-start;
219 gap: 10px;
220 padding: 10px 12px;
221 border: 1px solid var(--border);
222 border-radius: 8px;
223 background: var(--bg);
224}
225.pn-event-icon {
226 font-size: 18px;
227 line-height: 1;
228 margin-top: 1px;
229 flex-shrink: 0;
230}
231.pn-event-label {
232 font-size: 13px;
233 font-weight: 600;
234 margin-bottom: 2px;
235}
236.pn-event-hint {
237 font-size: 12px;
238 color: var(--text-muted);
239}
240
241/* Empty state */
242.pn-empty {
243 text-align: center;
244 padding: var(--space-5) var(--space-4);
245 color: var(--text-muted);
246 font-size: 14px;
247}
248
249/* Inline JS status */
250#pn-js-status {
251 font-size: 13px;
252 margin-top: var(--space-3);
253 color: var(--text-muted);
254 min-height: 1.4em;
255}
256#pn-js-status.ok { color: #3fb950; }
257#pn-js-status.err { color: #f85149; }
258`;
259
260// ---------------------------------------------------------------------------
261// Helper: format a subscription row for display
262// ---------------------------------------------------------------------------
263
264function fmtEndpoint(endpoint: string): string {
265 try {
266 const u = new URL(endpoint);
267 return `${u.host}${u.pathname.slice(0, 32)}…`;
268 } catch {
269 return endpoint.slice(0, 48) + "…";
270 }
271}
272
273function fmtDate(d: Date | null | undefined): string {
274 if (!d) return "—";
275 return d.toLocaleDateString("en-US", {
276 year: "numeric",
277 month: "short",
278 day: "numeric",
279 });
280}
281
282// ---------------------------------------------------------------------------
283// GET /settings/notifications/push
284// ---------------------------------------------------------------------------
285
286pushNotifRoutes.get("/settings/notifications/push", requireAuth, async (c) => {
287 const user = c.get("user")!;
288 const success = c.req.query("success");
289 const error = c.req.query("error");
290
291 const subs: PushSubscriptionRow[] = await listPushSubscriptions(user.id);
292
293 let vapidPublicKey = "";
294 try {
295 vapidPublicKey = await getVapidPublicKey();
296 } catch {
297 vapidPublicKey = "";
298 }
299
300 return c.html(
301 <Layout title="Push notifications" user={user}>
302 <style dangerouslySetInnerHTML={{ __html: PN_STYLES }} />
303 <div class="pn-wrap">
304 {/* Breadcrumb / hero */}
305 <div class="pn-hero">
306 <nav class="pn-crumbs" aria-label="Breadcrumb">
307 <a href="/settings">Settings</a>
308 <span>/</span>
309 <a href="/settings/notifications">Notifications</a>
310 <span>/</span>
311 <span>Push</span>
312 </nav>
313 <h1 class="pn-title">Browser push notifications</h1>
314 <p class="pn-sub">
315 Subscribe this browser to receive push notifications for deploys,
316 gate failures, PR merges, and AI reviews — even when Gluecron is
317 not open.
318 </p>
319 </div>
320
321 {/* Banner */}
322 {success && (
323 <div class="pn-banner" role="status">
324 <span class="pn-banner-dot" aria-hidden="true" />
325 {decodeURIComponent(success)}
326 </div>
327 )}
328 {error && (
329 <div class="pn-banner is-error" role="alert">
330 <span class="pn-banner-dot" aria-hidden="true" />
331 {decodeURIComponent(error)}
332 </div>
333 )}
334
335 {/* Subscribe card */}
336 <div class="pn-card">
337 <h2 class="pn-card-title">Subscribe this browser</h2>
338 <p class="pn-card-sub">
339 Click the button below to request permission and register this
340 browser. You can subscribe multiple devices independently.
341 </p>
342
343 <div style="display:flex;gap:10px;flex-wrap:wrap;align-items:center">
344 <button
345 type="button"
346 class="pn-enable-btn"
347 id="pn-subscribe-btn"
348 data-vapid={vapidPublicKey}
349 >
350 <svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
351 <path d="M8 16a2 2 0 002-2H6a2 2 0 002 2zm.995-14.901a1 1 0 10-1.99 0A5.002 5.002 0 003 6c0 1.098-.5 6-2 7h14c-1.5-1-2-5.902-2-7 0-2.42-1.72-4.44-4.005-4.901z"/>
352 </svg>
353 Enable push on this browser
354 </button>
355 <button
356 type="button"
357 class="pn-secondary-btn"
358 id="pn-test-btn"
359 style="display:none"
360 >
361 Send test notification
362 </button>
363 </div>
364 <p id="pn-js-status" aria-live="polite"></p>
365 </div>
366
367 {/* Tracked events */}
368 <div class="pn-card">
369 <h2 class="pn-card-title">Tracked events</h2>
370 <p class="pn-card-sub">
371 These events will trigger a push notification when they occur.
372 Per-event toggles live in{" "}
373 <a href="/settings/notifications" style="color:var(--accent)">
374 Notification preferences
375 </a>
376 .
377 </p>
378 <ul class="pn-events-grid" aria-label="Push events">
379 <li class="pn-event-row">
380 <span class="pn-event-icon" aria-hidden="true">🚀</span>
381 <div>
382 <div class="pn-event-label">Deploy succeeded</div>
383 <div class="pn-event-hint">
384 Your push went live — includes a link to the push watch page.
385 </div>
386 </div>
387 </li>
388 <li class="pn-event-row">
389 <span class="pn-event-icon" aria-hidden="true">🚨</span>
390 <div>
391 <div class="pn-event-label">Gate failed</div>
392 <div class="pn-event-hint">
393 A security or quality gate rejected your push.
394 </div>
395 </div>
396 </li>
397 <li class="pn-event-row">
398 <span class="pn-event-icon" aria-hidden="true">✅</span>
399 <div>
400 <div class="pn-event-label">PR merged</div>
401 <div class="pn-event-hint">
402 One of your pull requests was merged.
403 </div>
404 </div>
405 </li>
406 <li class="pn-event-row">
407 <span class="pn-event-icon" aria-hidden="true">🤖</span>
408 <div>
409 <div class="pn-event-label">AI review posted</div>
410 <div class="pn-event-hint">
411 The AI reviewer completed a pass on your PR.
412 </div>
413 </div>
414 </li>
415 </ul>
416 </div>
417
418 {/* Active subscriptions */}
419 <div class="pn-card">
420 <h2 class="pn-card-title">Active subscriptions</h2>
421 <p class="pn-card-sub">
422 Each row is a browser/device that will receive notifications.
423 Stale endpoints are cleaned up automatically after a failed
424 delivery.
425 </p>
426 {subs.length === 0 ? (
427 <p class="pn-empty">No active push subscriptions yet.</p>
428 ) : (
429 <ul class="pn-sub-list" aria-label="Active subscriptions">
430 {subs.map((s) => (
431 <li class="pn-sub-item" key={s.id}>
432 <div class="pn-sub-meta">
433 <div class="pn-sub-endpoint" title={s.endpoint}>
434 {fmtEndpoint(s.endpoint)}
435 </div>
436 {s.userAgent && (
437 <div class="pn-sub-date" title={s.userAgent}>
438 {s.userAgent.slice(0, 60)}
439 </div>
440 )}
441 <div class="pn-sub-date">Added {fmtDate(s.createdAt)}</div>
442 </div>
443 <form method="post" action="/api/push/unsubscribe">
444 <input type="hidden" name="endpoint" value={s.endpoint} />
445 <button type="submit" class="pn-remove-btn" aria-label="Remove subscription">
446 Remove
447 </button>
448 </form>
449 </li>
450 ))}
451 </ul>
452 )}
453 </div>
454 </div>
455
456 {/* Client-side subscription logic */}
457 <script
458 dangerouslySetInnerHTML={{
459 __html: `
460(function () {
461 var btn = document.getElementById('pn-subscribe-btn');
462 var testBtn = document.getElementById('pn-test-btn');
463 var status = document.getElementById('pn-js-status');
464 var vapidKey = btn ? btn.getAttribute('data-vapid') : '';
465
466 function setStatus(msg, cls) {
467 if (!status) return;
468 status.textContent = msg;
469 status.className = cls || '';
470 }
471
472 function urlBase64ToUint8Array(base64String) {
473 var padding = '='.repeat((4 - (base64String.length % 4)) % 4);
474 var base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/');
475 var raw = atob(base64);
476 var out = new Uint8Array(raw.length);
477 for (var i = 0; i < raw.length; i++) out[i] = raw.charCodeAt(i);
478 return out;
479 }
480
481 // Check if already subscribed
482 if ('serviceWorker' in navigator && 'PushManager' in window) {
483 navigator.serviceWorker.ready.then(function(reg) {
484 reg.pushManager.getSubscription().then(function(sub) {
485 if (sub && testBtn) {
486 testBtn.style.display = 'inline-flex';
487 setStatus('This browser is subscribed.', 'ok');
488 }
489 }).catch(function() {});
490 }).catch(function() {});
491 } else {
492 setStatus('Push notifications are not supported in this browser.', 'err');
493 if (btn) btn.disabled = true;
494 }
495
496 if (btn) {
497 btn.addEventListener('click', function () {
498 if (!('serviceWorker' in navigator && 'PushManager' in window)) {
499 setStatus('Push notifications are not supported in this browser.', 'err');
500 return;
501 }
502 if (!vapidKey) {
503 setStatus('Push notifications require server configuration (VAPID keys missing).', 'err');
504 return;
505 }
506 btn.disabled = true;
507 setStatus('Requesting permission…');
508
509 Notification.requestPermission().then(function(perm) {
510 if (perm !== 'granted') {
511 setStatus('Permission denied. Allow notifications in your browser settings.', 'err');
512 btn.disabled = false;
513 return;
514 }
515 return navigator.serviceWorker.ready.then(function(reg) {
516 return reg.pushManager.subscribe({
517 userVisibleOnly: true,
518 applicationServerKey: urlBase64ToUint8Array(vapidKey),
519 });
520 }).then(function(sub) {
521 var raw = sub.toJSON();
522 setStatus('Registering…');
523 return fetch('/api/push/subscribe', {
524 method: 'POST',
525 headers: { 'content-type': 'application/json' },
526 body: JSON.stringify({
527 endpoint: raw.endpoint,
528 keys: { p256dh: raw.keys.p256dh, auth: raw.keys.auth },
529 }),
530 });
531 }).then(function(res) {
532 if (res.ok) {
533 setStatus('Subscribed! Reload to see this device in the list.', 'ok');
534 if (testBtn) testBtn.style.display = 'inline-flex';
535 btn.disabled = false;
536 } else {
537 return res.json().then(function(j) {
538 setStatus('Subscribe failed: ' + (j.error || res.status), 'err');
539 btn.disabled = false;
540 });
541 }
542 }).catch(function(err) {
543 setStatus('Subscribe failed: ' + (err.message || err), 'err');
544 btn.disabled = false;
545 });
546 }).catch(function(err) {
547 setStatus('Permission request failed: ' + (err.message || err), 'err');
548 btn.disabled = false;
549 });
550 });
551 }
552
553 if (testBtn) {
554 testBtn.addEventListener('click', function () {
555 testBtn.disabled = true;
556 setStatus('Sending test…');
557 fetch('/api/push/test', { method: 'POST' })
558 .then(function(res) { return res.json(); })
559 .then(function(j) {
560 if (j.sent > 0) {
561 setStatus('Test notification sent! Check your browser.', 'ok');
562 } else if (j.failed > 0) {
563 setStatus('Delivery failed (sent:0 failed:' + j.failed + '). Check VAPID keys.', 'err');
564 } else {
565 setStatus('No active subscriptions found — subscribe this browser first.', 'err');
566 }
567 testBtn.disabled = false;
568 })
569 .catch(function(err) {
570 setStatus('Test failed: ' + (err.message || err), 'err');
571 testBtn.disabled = false;
572 });
573 });
574 }
575})();
576`,
577 }}
578 />
579 </Layout>
580 );
581});
582
583// ---------------------------------------------------------------------------
584// POST /api/push/subscribe
585// ---------------------------------------------------------------------------
586
587pushNotifRoutes.post("/api/push/subscribe", async (c) => {
588 const user = c.get("user")!;
589
590 let body: unknown;
591 try {
592 body = await c.req.json();
593 } catch {
594 return c.json({ error: "invalid_json" }, 400);
595 }
596
597 const b = body as Record<string, unknown>;
598 const endpoint = typeof b?.endpoint === "string" ? b.endpoint.trim() : "";
599 const keys = b?.keys as Record<string, unknown> | undefined;
600 const p256dh = typeof keys?.p256dh === "string" ? keys.p256dh.trim() : "";
601 const auth = typeof keys?.auth === "string" ? keys.auth.trim() : "";
602
603 if (!endpoint || !p256dh || !auth) {
604 return c.json({ error: "invalid_subscription" }, 400);
605 }
606
607 const ua = c.req.header("user-agent") ?? undefined;
608
609 try {
610 await savePushSubscription(user.id, { endpoint, keys: { p256dh, auth } }, ua);
611 } catch {
612 return c.json({ error: "subscribe_failed" }, 500);
613 }
614
615 return c.json({ ok: true }, 201);
616});
617
618// ---------------------------------------------------------------------------
619// POST /api/push/unsubscribe
620// ---------------------------------------------------------------------------
621
622pushNotifRoutes.post("/api/push/unsubscribe", async (c) => {
623 // Accept both form data (from the HTML form) and JSON.
624 let endpoint = "";
625 const ct = c.req.header("content-type") ?? "";
626 if (ct.includes("application/json")) {
627 let body: unknown;
628 try {
629 body = await c.req.json();
630 } catch {
631 return c.json({ error: "invalid_json" }, 400);
632 }
633 endpoint =
634 typeof (body as Record<string, unknown>)?.endpoint === "string"
635 ? ((body as Record<string, unknown>).endpoint as string).trim()
636 : "";
637 } else {
638 // form submission
639 const form = await c.req.formData();
640 endpoint = (form.get("endpoint") as string | null)?.trim() ?? "";
641 }
642
643 if (!endpoint) {
644 return c.json({ error: "missing_endpoint" }, 400);
645 }
646
647 await deletePushSubscription(endpoint);
648
649 // If this was a form submission, redirect back.
650 if (!ct.includes("application/json")) {
651 return c.redirect(
652 "/settings/notifications/push?success=" +
653 encodeURIComponent("Subscription removed."),
654 303
655 );
656 }
657
658 return c.body(null, 204);
659});
660
661// ---------------------------------------------------------------------------
662// POST /api/push/test
663// ---------------------------------------------------------------------------
664
665pushNotifRoutes.post("/api/push/test", async (c) => {
666 const user = c.get("user")!;
667 const result = await sendPushNotification(user.id, {
668 title: "Gluecron test notification",
669 body: "If you can read this, push delivery is working on this device.",
670 url: "/settings/notifications/push",
671 });
672 return c.json(result);
673});
674
675export default pushNotifRoutes;