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:
BIN
.pnpm-store/v11/index.db
Normal file
BIN
.pnpm-store/v11/index.db
Normal file
Binary file not shown.
116
app/components/EnableNotifications.vue
Normal file
116
app/components/EnableNotifications.vue
Normal 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 can’t receive push notifications.
|
||||
</p>
|
||||
<p v-else-if="state === 'misconfigured'" class="opacity-60">
|
||||
Push notifications aren’t configured.
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
@@ -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" />
|
||||
|
||||
@@ -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
93
pnpm-lock.yaml
generated
@@ -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
33
public/sw.js
Normal 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
10
scripts/generate-vapid.ts
Normal 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)
|
||||
Reference in New Issue
Block a user