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

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" />