feat(push): owner enrolment UI, service worker and VAPID keygen (T10)

EnableNotifications subscribes the owner's device with the public VAPID
key and stores it; sw.js receives pushes and opens /defects on click;
`pnpm vapid:keys` mints the key pair.
This commit is contained in:
Julien Calixte
2026-05-28 01:23:10 +02:00
parent ea7384591c
commit 0745ad14f0
7 changed files with 258 additions and 2 deletions

BIN
.pnpm-store/v11/index.db Normal file

Binary file not shown.

View File

@@ -0,0 +1,116 @@
<script setup lang="ts">
// Owner-only Web Push enrolment (T10/F8). Renders nothing unless the signed-in
// user is the configured owner. Registers the service worker, subscribes the
// device with the public VAPID key, and stores the subscription server-side;
// turning it off unsubscribes and prunes the row.
const { user } = useUserSession()
const config = useRuntimeConfig()
const ownerEmail = config.public.ownerEmail
const vapidPublicKey = config.public.vapidPublicKey
const isOwner = computed(
() =>
!!user.value?.email &&
!!ownerEmail &&
user.value.email.toLowerCase() === ownerEmail.toLowerCase(),
)
type State = 'loading' | 'idle' | 'subscribed' | 'denied' | 'unsupported' | 'misconfigured'
const state = ref<State>('loading')
const busy = ref(false)
const supported = () =>
import.meta.client &&
'serviceWorker' in navigator &&
'PushManager' in window &&
'Notification' in window
onMounted(async () => {
if (!isOwner.value) return
if (!supported()) return void (state.value = 'unsupported')
if (!vapidPublicKey) return void (state.value = 'misconfigured')
if (Notification.permission === 'denied') return void (state.value = 'denied')
const reg = await navigator.serviceWorker.getRegistration()
const existing = await reg?.pushManager.getSubscription()
state.value = existing ? 'subscribed' : 'idle'
})
async function enable() {
busy.value = true
try {
const reg = await navigator.serviceWorker.register('/sw.js')
await navigator.serviceWorker.ready
const permission = await Notification.requestPermission()
if (permission !== 'granted') {
state.value = permission === 'denied' ? 'denied' : 'idle'
return
}
const sub = await reg.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(vapidPublicKey),
})
await $fetch('/api/subscriptions', { method: 'POST', body: sub.toJSON() })
state.value = 'subscribed'
} finally {
busy.value = false
}
}
async function disable() {
busy.value = true
try {
const reg = await navigator.serviceWorker.getRegistration()
const sub = await reg?.pushManager.getSubscription()
if (sub) {
await $fetch('/api/subscriptions', { method: 'DELETE', body: { endpoint: sub.endpoint } })
await sub.unsubscribe()
}
state.value = 'idle'
} finally {
busy.value = false
}
}
// VAPID public keys are URL-safe base64; PushManager wants the raw bytes,
// backed by a concrete ArrayBuffer to satisfy the BufferSource type.
function urlBase64ToUint8Array(base64: string): Uint8Array<ArrayBuffer> {
const padding = '='.repeat((4 - (base64.length % 4)) % 4)
const normalised = (base64 + padding).replace(/-/g, '+').replace(/_/g, '/')
const raw = atob(normalised)
const bytes = new Uint8Array(new ArrayBuffer(raw.length))
for (let i = 0; i < raw.length; i++) bytes[i] = raw.charCodeAt(i)
return bytes
}
</script>
<template>
<div v-if="isOwner" class="text-sm">
<div v-if="state === 'subscribed'" class="flex items-center gap-2">
<span class="opacity-70">🔔 Notifications on</span>
<button type="button" class="btn btn-ghost btn-xs" :disabled="busy" @click="disable">
Turn off
</button>
</div>
<button
v-else-if="state === 'idle'"
type="button"
class="btn btn-outline btn-sm"
:disabled="busy"
@click="enable"
>
Enable notifications
</button>
<p v-else-if="state === 'denied'" class="opacity-60">
Notifications are blocked in your browser settings.
</p>
<p v-else-if="state === 'unsupported'" class="opacity-60">
This browser cant receive push notifications.
</p>
<p v-else-if="state === 'misconfigured'" class="opacity-60">
Push notifications arent configured.
</p>
</div>
</template>

View File

@@ -29,9 +29,10 @@ const openSection = ref<string | null>(null)
<template>
<main class="mx-auto flex max-w-5xl flex-col items-center gap-6 p-6">
<header class="text-center">
<header class="flex flex-col items-center gap-2 text-center">
<h1>Andon weak points</h1>
<p class="opacity-70">Every defect filed on the board.</p>
<EnableNotifications />
</header>
<DotMap :defects="defects" :counts="counts" @select="openSection = $event" />

View File

@@ -17,6 +17,7 @@
"db:migrate": "tsx server/db/migrate.ts",
"db:seed": "tsx server/db/seed.ts",
"db:verify": "tsx scripts/verify-db.ts",
"vapid:keys": "tsx scripts/generate-vapid.ts",
"docker:dev": "docker compose -f docker-compose.dev.yml up",
"docker:dev:build": "docker compose -f docker-compose.dev.yml up --build",
"docker:dev:down": "docker compose -f docker-compose.dev.yml down"
@@ -28,13 +29,15 @@
"nuxt-auth-utils": "^0.5.29",
"pg": "^8.21.0",
"vue": "^3.5.34",
"vue-router": "^5.0.7"
"vue-router": "^5.0.7",
"web-push": "^3.6.7"
},
"devDependencies": {
"@nuxt/eslint": "^1.15.2",
"@nuxt/test-utils": "^4.0.3",
"@tailwindcss/vite": "^4.3.0",
"@types/pg": "^8.20.0",
"@types/web-push": "^3.6.4",
"@vue/test-utils": "^2.4.10",
"daisyui": "^5.5.20",
"drizzle-kit": "^0.31.10",

93
pnpm-lock.yaml generated
View File

@@ -29,6 +29,9 @@ importers:
vue-router:
specifier: ^5.0.7
version: 5.0.7(@vue/compiler-sfc@3.5.34)(vue@3.5.34(typescript@6.0.3))
web-push:
specifier: ^3.6.7
version: 3.6.7
devDependencies:
'@nuxt/eslint':
specifier: ^1.15.2
@@ -42,6 +45,9 @@ importers:
'@types/pg':
specifier: ^8.20.0
version: 8.20.0
'@types/web-push':
specifier: ^3.6.4
version: 3.6.4
'@vue/test-utils':
specifier: ^2.4.10
version: 2.4.10(@vue/compiler-dom@3.5.34)(@vue/server-renderer@3.5.34(vue@3.5.34(typescript@6.0.3)))(vue@3.5.34(typescript@6.0.3))
@@ -2086,6 +2092,9 @@ packages:
'@types/resolve@1.20.2':
resolution: {integrity: sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==}
'@types/web-push@3.6.4':
resolution: {integrity: sha512-GnJmSr40H3RAnj0s34FNTcJi1hmWFV5KXugE0mYWnYhgTAHLJ/dJKAwDmvPJYMke0RplY2XE9LnM4hqSqKIjhQ==}
'@types/whatwg-mimetype@3.0.2':
resolution: {integrity: sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==}
@@ -2481,6 +2490,9 @@ packages:
arktype@2.2.0:
resolution: {integrity: sha512-t54MZ7ti5BhOEvzEkgKnWvqj+UbDfWig+DHr5I34xatymPusKLS0lQpNJd8M6DzmIto2QGszHfNKoFIT8tMCZQ==}
asn1.js@5.4.1:
resolution: {integrity: sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==}
assertion-error@2.0.1:
resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==}
engines: {node: '>=12'}
@@ -2579,6 +2591,9 @@ packages:
birpc@4.0.0:
resolution: {integrity: sha512-LShSxJP0KTmd101b6DRyGBj57LZxSDYWKitQNW/mi8GRMvZb078Uf9+pveax1DrVL89vm7mWe+TovdI/UDOuPw==}
bn.js@4.12.3:
resolution: {integrity: sha512-fGTi3gxV/23FTYdAoUtLYp6qySe2KE3teyZitipKNRuVYcBkoP/bB3guXN/XVKUe9mxCHXnc9C4ocyz8OmgN0g==}
boolbase@1.0.0:
resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==}
@@ -2602,6 +2617,9 @@ packages:
resolution: {integrity: sha512-Db1SbgBS/fg/392AblrMJk97KggmvYhr4pB5ZIMTWtaivCPMWLkmb7m21cJvpvgK+J3nsU2CmmixNBZx4vFj/w==}
engines: {node: '>=8.0.0'}
buffer-equal-constant-time@1.0.1:
resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==}
buffer-from@1.1.2:
resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==}
@@ -3031,6 +3049,9 @@ packages:
eastasianwidth@0.2.0:
resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==}
ecdsa-sig-formatter@1.0.11:
resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==}
editorconfig@1.0.7:
resolution: {integrity: sha512-e0GOtq/aTQhVdNyDU9e02+wz9oDDM+SIOQxWME2QRjzRX5yyLAuHDE+0aE8vHb9XRC8XD37eO2u57+F09JqFhw==}
engines: {node: '>=14'}
@@ -3503,6 +3524,10 @@ packages:
resolution: {integrity: sha512-S9wWkJ/VSY9/k4qcjG318bqJNruzE4HySUhFYknwmu6LBP97KLLfwNf+n4V1BHurvFNkSKLFnK/RsuUnRTf9Vw==}
engines: {iojs: '>= 1.0.0', node: '>= 0.12.0'}
http_ece@1.2.0:
resolution: {integrity: sha512-JrF8SSLVmcvc5NducxgyOrKXe3EsyHMgBFgSaIUGmArKe+rwr0uphRkRXvwiom3I+fpIfoItveHrfudL8/rxuA==}
engines: {node: '>=16'}
https-proxy-agent@7.0.6:
resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==}
engines: {node: '>= 14'}
@@ -3685,6 +3710,12 @@ packages:
engines: {node: '>=6'}
hasBin: true
jwa@2.0.1:
resolution: {integrity: sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==}
jws@4.0.1:
resolution: {integrity: sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==}
keyv@4.5.4:
resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==}
@@ -3874,6 +3905,9 @@ packages:
resolution: {integrity: sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==}
engines: {node: '>=12'}
minimalistic-assert@1.0.1:
resolution: {integrity: sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==}
minimatch@10.2.5:
resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==}
engines: {node: 18 || 20 || >=22}
@@ -3886,6 +3920,9 @@ packages:
resolution: {integrity: sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==}
engines: {node: '>=16 || 14 >=14.17'}
minimist@1.2.8:
resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==}
minipass@7.1.3:
resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==}
engines: {node: '>=16 || 14 >=14.17'}
@@ -4551,6 +4588,9 @@ packages:
resolution: {integrity: sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==}
engines: {node: '>=10'}
safer-buffer@2.1.2:
resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==}
sax@1.6.0:
resolution: {integrity: sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==}
engines: {node: '>=11.0.0'}
@@ -5196,6 +5236,11 @@ packages:
typescript:
optional: true
web-push@3.6.7:
resolution: {integrity: sha512-OpiIUe8cuGjrj3mMBFWY+e4MMIkW3SVT+7vEIjvD9kejGUypv8GPDf84JdPWskK8zMRIJ6xYGm+Kxr8YkPyA0A==}
engines: {node: '>= 16'}
hasBin: true
webidl-conversions@3.0.1:
resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==}
@@ -7044,6 +7089,10 @@ snapshots:
'@types/resolve@1.20.2': {}
'@types/web-push@3.6.4':
dependencies:
'@types/node': 25.9.1
'@types/whatwg-mimetype@3.0.2': {}
'@types/ws@8.18.1':
@@ -7498,6 +7547,13 @@ snapshots:
'@ark/util': 0.56.0
arkregex: 0.0.5
asn1.js@5.4.1:
dependencies:
bn.js: 4.12.3
inherits: 2.0.4
minimalistic-assert: 1.0.1
safer-buffer: 2.1.2
assertion-error@2.0.1: {}
ast-kit@2.2.0:
@@ -7573,6 +7629,8 @@ snapshots:
birpc@4.0.0: {}
bn.js@4.12.3: {}
boolbase@1.0.0: {}
brace-expansion@2.1.1:
@@ -7597,6 +7655,8 @@ snapshots:
buffer-crc32@1.0.0: {}
buffer-equal-constant-time@1.0.1: {}
buffer-from@1.1.2: {}
buffer@6.0.3:
@@ -7905,6 +7965,10 @@ snapshots:
eastasianwidth@0.2.0: {}
ecdsa-sig-formatter@1.0.11:
dependencies:
safe-buffer: 5.2.1
editorconfig@1.0.7:
dependencies:
'@one-ini/wasm': 0.1.1
@@ -8500,6 +8564,8 @@ snapshots:
http-shutdown@1.2.2: {}
http_ece@1.2.0: {}
https-proxy-agent@7.0.6:
dependencies:
agent-base: 7.1.4
@@ -8649,6 +8715,17 @@ snapshots:
json5@2.2.3: {}
jwa@2.0.1:
dependencies:
buffer-equal-constant-time: 1.0.1
ecdsa-sig-formatter: 1.0.11
safe-buffer: 5.2.1
jws@4.0.1:
dependencies:
jwa: 2.0.1
safe-buffer: 5.2.1
keyv@4.5.4:
dependencies:
json-buffer: 3.0.1
@@ -8821,6 +8898,8 @@ snapshots:
mimic-fn@4.0.0: {}
minimalistic-assert@1.0.1: {}
minimatch@10.2.5:
dependencies:
brace-expansion: 5.0.6
@@ -8833,6 +8912,8 @@ snapshots:
dependencies:
brace-expansion: 2.1.1
minimist@1.2.8: {}
minipass@7.1.3: {}
minizlib@3.1.0:
@@ -9725,6 +9806,8 @@ snapshots:
safe-stable-stringify@2.5.0: {}
safer-buffer@2.1.2: {}
sax@1.6.0: {}
scslre@0.3.0:
@@ -10378,6 +10461,16 @@ snapshots:
optionalDependencies:
typescript: 6.0.3
web-push@3.6.7:
dependencies:
asn1.js: 5.4.1
http_ece: 1.2.0
https-proxy-agent: 7.0.6
jws: 4.0.1
minimist: 1.2.8
transitivePeerDependencies:
- supports-color
webidl-conversions@3.0.1: {}
webpack-virtual-modules@0.6.2: {}

33
public/sw.js Normal file
View File

@@ -0,0 +1,33 @@
// Web Push service worker (T10). Registered from EnableNotifications.vue and
// served at the site root so it controls the whole origin. Its job is to
// receive pushes the server sends (T11) and surface them as OS notifications.
self.addEventListener('push', (event) => {
let payload = {}
try {
payload = event.data ? event.data.json() : {}
} catch {
payload = {}
}
const title = payload.title || 'New defect filed'
event.waitUntil(
self.registration.showNotification(title, {
body: payload.body || '',
tag: payload.tag,
data: { url: payload.url || '/defects' },
}),
)
})
self.addEventListener('notificationclick', (event) => {
event.notification.close()
const target = (event.notification.data && event.notification.data.url) || '/defects'
event.waitUntil(
self.clients.matchAll({ type: 'window', includeUncontrolled: true }).then((clients) => {
for (const client of clients) {
if (client.url.includes(target) && 'focus' in client) return client.focus()
}
return self.clients.openWindow(target)
}),
)
})

10
scripts/generate-vapid.ts Normal file
View File

@@ -0,0 +1,10 @@
// One-off helper to mint a VAPID key pair for Web Push (T10).
// Run with `pnpm vapid:keys`, then paste the values into your .env:
// NUXT_PUBLIC_VAPID_PUBLIC_KEY (sent to the browser)
// NUXT_VAPID_PRIVATE_KEY (server secret — never expose)
import webpush from 'web-push'
const { publicKey, privateKey } = webpush.generateVAPIDKeys()
console.log('NUXT_PUBLIC_VAPID_PUBLIC_KEY=' + publicKey)
console.log('NUXT_VAPID_PRIVATE_KEY=' + privateKey)