feat: Benachrichtigungen in den Einstellungen bedienen

Anmelden dieses Browsers, Liste der angemeldeten Geraete, Auswahl der
Anlaesse, Ruhezeit und Vorlauf. Ist ein Weg auf dem Server nicht
eingerichtet, steht das direkt daneben statt in einem Fehler beim
Speichern.

Der Knopf "Probe senden" umgeht Ruhezeit und Sperre absichtlich: wer
darauf drueckt, will jetzt wissen, ob es ankommt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Florian Wilde
2026-09-11 08:59:57 +02:00
co-authored by Claude Opus 5
parent e0e11c85b2
commit b622c1bb28
3 changed files with 700 additions and 0 deletions
@@ -0,0 +1,439 @@
'use client'
import { useEffect, useState, useTransition } from 'react'
import { useRouter } from 'next/navigation'
import { Bell, BellOff, Loader2, Send, Trash2 } from 'lucide-react'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Kennzeichen } from '@/components/ui/kennzeichen'
import {
ANLAESSE,
ANLASS_BESCHREIBUNG,
ANLASS_NAME,
type Anlass,
} from '@/lib/benachrichtigung'
import {
einstellungSpeichern,
geraetEntfernen,
probeSenden,
type Rueckmeldung,
} from '@/server/aktionen/benachrichtigungen'
export type GeraetAnzeige = {
id: string
bezeichnung: string | null
erstelltAm: string
zuletztBenutzt: string | null
/** Dieses Gerät ist der Browser, in dem die Seite gerade offen ist. */
diesesGeraet?: boolean
}
export type EinstellungAnzeige = {
anlaesse: Anlass[]
perPush: boolean
perMail: boolean
ueberblickUm: string
ruheVon: string
ruheBis: string
ruheAmWochenende: boolean
zeitblockVorlaufMinuten: number
}
type Eigenschaften = {
einstellung: EinstellungAnzeige
geraete: GeraetAnzeige[]
pushMoeglich: boolean
mailMoeglich: boolean
}
/**
* Wandelt den base64url-Schlüssel in die Form, die der Browser erwartet.
*
* Der Puffer wird ausdrücklich angelegt, damit der Typ `Uint8Array<ArrayBuffer>`
* lautet — `pushManager.subscribe` nimmt keinen geteilten Puffer an.
*/
function schluesselAlsBytes(base64url: string): Uint8Array<ArrayBuffer> {
const fuellung = '='.repeat((4 - (base64url.length % 4)) % 4)
const base64 = (base64url + fuellung).replace(/-/g, '+').replace(/_/g, '/')
const roh = atob(base64)
const bytes = new Uint8Array(new ArrayBuffer(roh.length))
for (let i = 0; i < roh.length; i += 1) bytes[i] = roh.charCodeAt(i)
return bytes
}
export function Benachrichtigungen({
einstellung,
geraete,
pushMoeglich,
mailMoeglich,
}: Eigenschaften) {
const router = useRouter()
const [laeuft, starte] = useTransition()
const [felder, setFelder] = useState<Record<string, string>>({})
const [pushZustand, setPushZustand] = useState<
'unbekannt' | 'nicht-unterstuetzt' | 'aus' | 'an' | 'verweigert'
>('unbekannt')
const [pushLaeuft, setPushLaeuft] = useState(false)
useEffect(() => {
let abgebrochen = false
async function zustandPruefen() {
if (!('serviceWorker' in navigator) || !('PushManager' in window)) {
if (!abgebrochen) setPushZustand('nicht-unterstuetzt')
return
}
if (Notification.permission === 'denied') {
if (!abgebrochen) setPushZustand('verweigert')
return
}
const registrierung = await navigator.serviceWorker.getRegistration()
const abo = await registrierung?.pushManager.getSubscription()
if (!abgebrochen) setPushZustand(abo ? 'an' : 'aus')
}
void zustandPruefen()
return () => {
abgebrochen = true
}
}, [])
function mitRueckmeldung(tun: () => Promise<Rueckmeldung>) {
starte(async () => {
const antwort = await tun()
if (antwort.ok) {
setFelder({})
toast.success(antwort.meldung)
router.refresh()
} else {
setFelder(antwort.felder ?? {})
toast.error(antwort.fehler)
}
})
}
async function pushAnmelden() {
setPushLaeuft(true)
try {
const erlaubnis = await Notification.requestPermission()
if (erlaubnis !== 'granted') {
setPushZustand(erlaubnis === 'denied' ? 'verweigert' : 'aus')
toast.error('Ohne Erlaubnis des Browsers geht es nicht.')
return
}
const antwort = await fetch('/api/push')
const daten = (await antwort.json()) as {
aktiv: boolean
oeffentlicherSchluessel: string | null
}
if (!daten.aktiv || !daten.oeffentlicherSchluessel) {
toast.error('Push ist auf diesem Server nicht eingerichtet.')
return
}
const registrierung = await navigator.serviceWorker.register('/sw.js')
await navigator.serviceWorker.ready
const abo = await registrierung.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: schluesselAlsBytes(daten.oeffentlicherSchluessel),
})
const roh = abo.toJSON() as { endpoint?: string; keys?: Record<string, string> }
const gespeichert = await fetch('/api/push', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
endpunkt: roh.endpoint,
p256dh: roh.keys?.p256dh,
auth: roh.keys?.auth,
bezeichnung: geraetename(),
}),
})
if (!gespeichert.ok) {
const fehler = (await gespeichert.json()) as { fehler?: string }
toast.error(fehler.fehler ?? 'Die Anmeldung ließ sich nicht speichern.')
return
}
setPushZustand('an')
toast.success('Dieses Gerät empfängt jetzt Benachrichtigungen.')
router.refresh()
} catch {
toast.error('Die Anmeldung hat nicht geklappt.')
} finally {
setPushLaeuft(false)
}
}
async function pushAbmelden() {
setPushLaeuft(true)
try {
const registrierung = await navigator.serviceWorker.getRegistration()
const abo = await registrierung?.pushManager.getSubscription()
if (abo) {
await fetch('/api/push', {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ endpunkt: abo.endpoint }),
})
await abo.unsubscribe()
}
setPushZustand('aus')
toast.success('Dieses Gerät empfängt keine Benachrichtigungen mehr.')
router.refresh()
} catch {
toast.error('Das Abmelden hat nicht geklappt.')
} finally {
setPushLaeuft(false)
}
}
return (
<div className="flex flex-col gap-6">
{/* Dieses Gerät */}
<div className="border-rand flex flex-wrap items-center gap-3 rounded-md border p-3">
{pushZustand === 'an' ? (
<Bell className="text-erfolg size-5 shrink-0" aria-hidden />
) : (
<BellOff className="text-gedaempft-vordergrund size-5 shrink-0" aria-hidden />
)}
<div className="min-w-0 flex-1">
<p className="text-sm font-medium">Dieses Gerät</p>
<p className="text-gedaempft-vordergrund text-xs">
{pushZustand === 'nicht-unterstuetzt' &&
'Dieser Browser kann keine Push-Nachrichten empfangen.'}
{pushZustand === 'verweigert' &&
'Der Browser hat Benachrichtigungen blockiert. Das lässt sich nur in seinen Einstellungen ändern.'}
{pushZustand === 'an' && 'Empfängt Push-Nachrichten.'}
{pushZustand === 'aus' && 'Empfängt keine Push-Nachrichten.'}
{pushZustand === 'unbekannt' && '…'}
</p>
</div>
{!pushMoeglich ? (
<Kennzeichen art="warnung">Auf dem Server nicht eingerichtet</Kennzeichen>
) : pushZustand === 'an' ? (
<Button art="umriss" disabled={pushLaeuft} onClick={() => void pushAbmelden()}>
{pushLaeuft && <Loader2 className="animate-spin" aria-hidden />}
Abmelden
</Button>
) : (
pushZustand !== 'nicht-unterstuetzt' &&
pushZustand !== 'verweigert' && (
<Button disabled={pushLaeuft} onClick={() => void pushAnmelden()}>
{pushLaeuft && <Loader2 className="animate-spin" aria-hidden />}
Anmelden
</Button>
)
)}
</div>
{/* Einstellungen */}
<form
className="flex flex-col gap-5"
onSubmit={(ereignis) => {
ereignis.preventDefault()
const daten = new FormData(ereignis.currentTarget)
mitRueckmeldung(() => einstellungSpeichern(daten))
}}
>
<fieldset className="flex flex-col gap-3">
<legend className="mb-1 text-sm font-medium">Worüber</legend>
{ANLAESSE.map((anlass) => (
<label key={anlass} className="flex items-start gap-3">
<input
type="checkbox"
name="anlaesse"
value={anlass}
defaultChecked={einstellung.anlaesse.includes(anlass)}
className="accent-betont mt-0.5 size-4"
/>
<span>
<span className="text-sm">{ANLASS_NAME[anlass]}</span>
<span className="text-gedaempft-vordergrund block text-xs">
{ANLASS_BESCHREIBUNG[anlass]}
</span>
</span>
</label>
))}
</fieldset>
<fieldset className="flex flex-col gap-3">
<legend className="mb-1 text-sm font-medium">Wie</legend>
<label className="flex items-center gap-3">
<input
type="checkbox"
name="perPush"
defaultChecked={einstellung.perPush}
className="accent-betont size-4"
/>
<span className="text-sm">
Push an angemeldete Geräte
{!pushMoeglich && (
<span className="text-gedaempft-vordergrund">
{' '}
— auf diesem Server nicht eingerichtet
</span>
)}
</span>
</label>
<label className="flex items-center gap-3">
<input
type="checkbox"
name="perMail"
defaultChecked={einstellung.perMail}
className="accent-betont size-4"
/>
<span className="text-sm">
E-Mail
{!mailMoeglich && (
<span className="text-gedaempft-vordergrund">
{' '}
— auf diesem Server nicht eingerichtet
</span>
)}
</span>
</label>
</fieldset>
<div className="grid gap-4 sm:grid-cols-2">
<div className="flex flex-col gap-2">
<Label htmlFor="ueberblickUm">Tagesüberblick um</Label>
<Input
id="ueberblickUm"
name="ueberblickUm"
type="time"
defaultValue={einstellung.ueberblickUm}
aria-invalid={Boolean(felder.ueberblickUm)}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="zeitblockVorlaufMinuten">
Vorlauf vor einem Zeitblock (Minuten)
</Label>
<Input
id="zeitblockVorlaufMinuten"
name="zeitblockVorlaufMinuten"
type="number"
min={0}
max={120}
step={5}
defaultValue={einstellung.zeitblockVorlaufMinuten}
aria-invalid={Boolean(felder.zeitblockVorlaufMinuten)}
/>
{felder.zeitblockVorlaufMinuten && (
<p className="text-gefahr text-xs">{felder.zeitblockVorlaufMinuten}</p>
)}
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="ruheVon">Ruhe von</Label>
<Input
id="ruheVon"
name="ruheVon"
type="time"
defaultValue={einstellung.ruheVon}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="ruheBis">bis</Label>
<Input
id="ruheBis"
name="ruheBis"
type="time"
defaultValue={einstellung.ruheBis}
/>
</div>
</div>
<label className="flex items-center gap-3">
<input
type="checkbox"
name="ruheAmWochenende"
defaultChecked={einstellung.ruheAmWochenende}
className="accent-betont size-4"
/>
<span className="text-sm">Am Wochenende gar nichts melden</span>
</label>
<p className="text-gedaempft-vordergrund text-xs">
In der Ruhezeit bleibt alles still — bis auf den Tagesüberblick. Der käme sonst
nie, wenn du früh aufstehst.
</p>
<div className="flex flex-wrap gap-2">
<Button type="submit" disabled={laeuft}>
{laeuft && <Loader2 className="animate-spin" aria-hidden />}
Speichern
</Button>
<Button
type="button"
art="umriss"
disabled={laeuft}
onClick={() => mitRueckmeldung(() => probeSenden())}
>
<Send aria-hidden />
Probe schicken
</Button>
</div>
</form>
{/* Angemeldete Geräte */}
{geraete.length > 0 && (
<div className="flex flex-col gap-2">
<h3 className="text-sm font-medium">Angemeldete Geräte</h3>
<ul className="divide-rand divide-y">
{geraete.map((geraet) => (
<li key={geraet.id} className="flex items-center gap-3 py-2">
<div className="min-w-0 flex-1">
<p className="text-sm">{geraet.bezeichnung ?? 'Unbenanntes Gerät'}</p>
<p className="text-gedaempft-vordergrund text-xs">
Angemeldet {geraet.erstelltAm}
{geraet.zuletztBenutzt
? ` · zuletzt benachrichtigt ${geraet.zuletztBenutzt}`
: ' · noch nichts empfangen'}
</p>
</div>
<Button
art="leise"
groesse="quadrat"
title={`${geraet.bezeichnung ?? 'Gerät'} abmelden`}
disabled={laeuft}
onClick={() => {
const daten = new FormData()
daten.set('id', geraet.id)
mitRueckmeldung(() => geraetEntfernen(daten))
}}
>
<Trash2 aria-hidden />
<span className="sr-only">Abmelden</span>
</Button>
</li>
))}
</ul>
</div>
)}
</div>
)
}
/** Grobe Kennung des Browsers, damit sich Geräte auseinanderhalten lassen. */
function geraetename(): string {
const kennung = navigator.userAgent
if (/Android/i.test(kennung)) return 'Android-Gerät'
if (/iPhone|iPad/i.test(kennung)) return 'iPhone oder iPad'
if (/Macintosh/i.test(kennung)) return 'Mac'
if (/Windows/i.test(kennung)) return 'Windows-Rechner'
if (/Linux/i.test(kennung)) return 'Linux-Rechner'
return 'Unbekanntes Gerät'
}
+66
View File
@@ -1,4 +1,5 @@
import type { Metadata } from 'next'
import { Benachrichtigungen } from '@/app/(app)/einstellungen/benachrichtigungen'
import { PasskeyVerwaltung } from '@/app/(app)/einstellungen/passkey-verwaltung'
import {
Card,
@@ -8,6 +9,9 @@ import {
CardTitel,
} from '@/components/ui/card'
import { ROLLEN_NAME } from '@/lib/rollen'
import { alsEinstellung } from '@/server/benachrichtigung/versand'
import { alsUhrzeit } from '@/lib/zeit'
import { umgebung } from '@/server/umgebung'
import { alsDatumUhrzeit } from '@/lib/zeit'
import { verlangeAnmeldung } from '@/server/berechtigung'
import { db } from '@/server/db'
@@ -17,6 +21,35 @@ export const metadata: Metadata = { title: 'Einstellungen' }
export default async function EinstellungenSeite() {
const benutzer = await verlangeAnmeldung('/einstellungen')
const einst = umgebung()
const [benachrichtigung, pushGeraete] = await Promise.all([
db.benachrichtigungseinstellung.findUnique({
where: { benutzerId: benutzer.id },
select: {
anlaesse: true,
perPush: true,
perMail: true,
ueberblickUm: true,
ruheVon: true,
ruheBis: true,
ruheAmWochenende: true,
zeitblockVorlaufMinuten: true,
},
}),
db.pushGeraet.findMany({
where: { benutzerId: benutzer.id },
orderBy: { erstelltAm: 'asc' },
select: {
id: true,
bezeichnung: true,
erstelltAm: true,
zuletztBenutzt: true,
},
}),
])
const einstellung = alsEinstellung(benachrichtigung)
const passkeys = await db.passkey.findMany({
where: { benutzerId: benutzer.id },
orderBy: { erstelltAm: 'asc' },
@@ -72,6 +105,39 @@ export default async function EinstellungenSeite() {
/>
</CardInhalt>
</Card>
<Card data-bild="benachrichtigungen">
<CardKopf>
<CardTitel className="text-base">Benachrichtigungen</CardTitel>
<CardBeschreibung>
Ab Werk meldet der Planer gar nichts. Was du hier einschaltest, kommt — alles
andere nicht. Eine Anwendung, die zu oft meldet, wird stummgeschaltet und ist
dann wertlos.
</CardBeschreibung>
</CardKopf>
<CardInhalt>
<Benachrichtigungen
pushMoeglich={einst.pushAktiv}
mailMoeglich={einst.mailAktiv}
einstellung={{
anlaesse: [...einstellung.anlaesse],
perPush: einstellung.perPush,
perMail: einstellung.perMail,
ueberblickUm: alsUhrzeit(einstellung.ueberblickUm),
ruheVon: alsUhrzeit(einstellung.ruheVon),
ruheBis: alsUhrzeit(einstellung.ruheBis),
ruheAmWochenende: einstellung.ruheAmWochenende,
zeitblockVorlaufMinuten: einstellung.zeitblockVorlaufMinuten,
}}
geraete={pushGeraete.map((g) => ({
id: g.id,
bezeichnung: g.bezeichnung,
erstelltAm: alsDatumUhrzeit(g.erstelltAm),
zuletztBenutzt: g.zuletztBenutzt ? alsDatumUhrzeit(g.zuletztBenutzt) : null,
}))}
/>
</CardInhalt>
</Card>
</div>
)
}