feat(tickets): Web-Push-Benachrichtigungen (PWA)
- Die Züchterin kann auf der Tickets-Seite Push-Benachrichtigungen aktivieren (🔔-Schalter). Service Worker ist BEWUSST push-only (kein fetch/Caching), damit das Laden der App nie beeinträchtigt wird. - Backend: WebPush-Bibliothek + VAPID; PushNotifier sendet an alle Abos, räumt veraltete (404/410) auf. Endpoints: GET /push/vapid-public-key, POST /push/subscribe|unsubscribe. - Ausgelöst über ein notify-Flag auf PUT /feedback: NUR die KI setzt es (z. B. neue Rückfrage / Ticket gelöst) → keine Selbst-Pushes durch Aktionen der Züchterin. Text wird aus dem Status abgeleitet, Klick öffnet das Ticket (?focus=). - Schalter/Logik blenden sich aus, wenn das Gerät kein Push kann oder der Server keine VAPID-Schlüssel hat (z. B. e2e-Mock). Migration WebPushSubscriptions. VAPID-Schlüssel in appsettings.json (lokale Single-User-App im Heimnetz — bewusste, vertretbare Vereinfachung). Tests: 264 Backend grün (+Push-Endpoints), e2e Tickets Desktop grün, vitest 149. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
64
gerbil-manager-web/src/components/PushToggle.tsx
Normal file
64
gerbil-manager-web/src/components/PushToggle.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* Schalter „Benachrichtigungen aktivieren" (Web-Push / PWA).
|
||||
* Rendert nichts, wenn Push nicht unterstützt wird ODER der Server keine VAPID-Schlüssel hat
|
||||
* (z. B. im e2e-Mock) — so bleibt die UI dort unverändert.
|
||||
*/
|
||||
import { useEffect, useState } from 'react'
|
||||
import { de } from '../strings/de'
|
||||
import { useToast } from './toast'
|
||||
import { disablePush, enablePush, fetchPushConfig, isPushSupported, isSubscribed } from '../push'
|
||||
|
||||
export default function PushToggle() {
|
||||
const t = de.feedback.tickets
|
||||
const toast = useToast()
|
||||
const [available, setAvailable] = useState(false)
|
||||
const [subscribed, setSubscribed] = useState(false)
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
;(async () => {
|
||||
if (!isPushSupported()) return
|
||||
const cfg = await fetchPushConfig()
|
||||
if (cancelled || !cfg.enabled) return
|
||||
setAvailable(true)
|
||||
setSubscribed(await isSubscribed())
|
||||
})()
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [])
|
||||
|
||||
if (!available) return null
|
||||
|
||||
async function toggle() {
|
||||
setBusy(true)
|
||||
try {
|
||||
if (subscribed) {
|
||||
await disablePush()
|
||||
setSubscribed(false)
|
||||
toast.success(t.pushDisabledToast)
|
||||
} else {
|
||||
const res = await enablePush()
|
||||
if (res === 'enabled') {
|
||||
setSubscribed(true)
|
||||
toast.success(t.pushEnabledToast)
|
||||
} else if (res === 'denied') {
|
||||
toast.error(t.pushDenied)
|
||||
} else {
|
||||
toast.error(t.pushUnavailable)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
toast.error(t.pushUnavailable)
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<button type="button" className="btn tickets-push-toggle" onClick={toggle} disabled={busy}>
|
||||
{subscribed ? `🔕 ${t.pushDisable}` : `🔔 ${t.pushEnable}`}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -30,6 +30,7 @@ import { getGerbil } from '../api/gerbils'
|
||||
import { REF_ROUTE, REF_TOKEN_RE, resolveRefs, type RefType } from '../api/refs'
|
||||
import { useApi } from '../hooks/useApi'
|
||||
import { useToast } from '../components/toast'
|
||||
import PushToggle from '../components/PushToggle'
|
||||
import './tickets.css'
|
||||
|
||||
/** Aufgelöster Kurz-Verweis (Shortlink): null = unbekannt/mehrdeutig. */
|
||||
@@ -568,6 +569,7 @@ export default function TicketsPage() {
|
||||
</nav>
|
||||
<h2>{t.title}</h2>
|
||||
<p className="tickets-subtitle">{t.subtitle}</p>
|
||||
<PushToggle />
|
||||
|
||||
{tickets.error && <p className="tickets-error">{t.loadError}</p>}
|
||||
|
||||
|
||||
87
gerbil-manager-web/src/push.ts
Normal file
87
gerbil-manager-web/src/push.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* WEB-PUSH (PWA-Benachrichtigungen) — Client-Seite.
|
||||
* Registriert den Service Worker (push-only) und verwaltet das Abo gegen /push/*.
|
||||
*/
|
||||
import { API_BASE_URL } from './api/client'
|
||||
|
||||
export function isPushSupported(): boolean {
|
||||
return (
|
||||
typeof navigator !== 'undefined' &&
|
||||
'serviceWorker' in navigator &&
|
||||
typeof window !== 'undefined' &&
|
||||
'PushManager' in window &&
|
||||
'Notification' in window
|
||||
)
|
||||
}
|
||||
|
||||
/** Server-Status: ist Push konfiguriert + welcher VAPID-Public-Key. */
|
||||
export async function fetchPushConfig(): Promise<{ enabled: boolean; publicKey: string | null }> {
|
||||
try {
|
||||
const r = await fetch(`${API_BASE_URL}/push/vapid-public-key`)
|
||||
if (!r.ok) return { enabled: false, publicKey: null }
|
||||
return await r.json()
|
||||
} catch {
|
||||
return { enabled: false, publicKey: null }
|
||||
}
|
||||
}
|
||||
|
||||
function urlBase64ToUint8Array(base64: string): Uint8Array {
|
||||
const padding = '='.repeat((4 - (base64.length % 4)) % 4)
|
||||
const b64 = (base64 + padding).replace(/-/g, '+').replace(/_/g, '/')
|
||||
const raw = atob(b64)
|
||||
const arr = new Uint8Array(raw.length)
|
||||
for (let i = 0; i < raw.length; i++) arr[i] = raw.charCodeAt(i)
|
||||
return arr
|
||||
}
|
||||
|
||||
export async function isSubscribed(): Promise<boolean> {
|
||||
if (!isPushSupported()) return false
|
||||
const reg = await navigator.serviceWorker.getRegistration()
|
||||
const sub = await reg?.pushManager.getSubscription()
|
||||
return !!sub
|
||||
}
|
||||
|
||||
export type EnableResult = 'enabled' | 'denied' | 'unavailable'
|
||||
|
||||
export async function enablePush(): Promise<EnableResult> {
|
||||
if (!isPushSupported()) return 'unavailable'
|
||||
const cfg = await fetchPushConfig()
|
||||
if (!cfg.enabled || !cfg.publicKey) return 'unavailable'
|
||||
const permission = await Notification.requestPermission()
|
||||
if (permission !== 'granted') return 'denied'
|
||||
|
||||
const reg = await navigator.serviceWorker.register('/sw.js')
|
||||
await navigator.serviceWorker.ready
|
||||
const sub = await reg.pushManager.subscribe({
|
||||
userVisibleOnly: true,
|
||||
applicationServerKey: urlBase64ToUint8Array(cfg.publicKey),
|
||||
})
|
||||
const json = sub.toJSON()
|
||||
await fetch(`${API_BASE_URL}/push/subscribe`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
endpoint: sub.endpoint,
|
||||
p256dh: json.keys?.p256dh ?? '',
|
||||
auth: json.keys?.auth ?? '',
|
||||
}),
|
||||
})
|
||||
return 'enabled'
|
||||
}
|
||||
|
||||
export async function disablePush(): Promise<void> {
|
||||
if (!isPushSupported()) return
|
||||
const reg = await navigator.serviceWorker.getRegistration()
|
||||
const sub = await reg?.pushManager.getSubscription()
|
||||
if (!sub) return
|
||||
try {
|
||||
await fetch(`${API_BASE_URL}/push/unsubscribe`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ endpoint: sub.endpoint }),
|
||||
})
|
||||
} catch {
|
||||
/* trotzdem lokal abmelden */
|
||||
}
|
||||
await sub.unsubscribe()
|
||||
}
|
||||
@@ -1300,6 +1300,13 @@ export const de = {
|
||||
attachmentAdded: 'Anhang hinzugefügt.',
|
||||
attachmentRemoved: 'Anhang entfernt.',
|
||||
attachmentError: 'Anhang konnte nicht verarbeitet werden.',
|
||||
/** Push-Benachrichtigungen (PWA). */
|
||||
pushEnable: 'Benachrichtigungen aktivieren',
|
||||
pushDisable: 'Benachrichtigungen aus',
|
||||
pushEnabledToast: 'Benachrichtigungen aktiviert — du wirst informiert, sobald ich antworte.',
|
||||
pushDisabledToast: 'Benachrichtigungen deaktiviert.',
|
||||
pushDenied: 'Benachrichtigungen wurden im Browser blockiert. Bitte in den Browser-Einstellungen erlauben.',
|
||||
pushUnavailable: 'Benachrichtigungen sind auf diesem Gerät nicht verfügbar.',
|
||||
/** Changelog (fixNote) auf geschlossenen Tickets. */
|
||||
changelogLabel: 'Was wurde geändert',
|
||||
/** Überschrift des Frage/Antwort-Verlaufs (frühere Runden). */
|
||||
|
||||
Reference in New Issue
Block a user