diff --git a/src/app/(app)/einstellungen/benachrichtigungen.tsx b/src/app/(app)/einstellungen/benachrichtigungen.tsx new file mode 100644 index 0000000..c4c88d5 --- /dev/null +++ b/src/app/(app)/einstellungen/benachrichtigungen.tsx @@ -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` + * lautet — `pushManager.subscribe` nimmt keinen geteilten Puffer an. + */ +function schluesselAlsBytes(base64url: string): Uint8Array { + 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>({}) + 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) { + 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 } + 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 ( +
+ {/* Dieses Gerät */} +
+ {pushZustand === 'an' ? ( + + ) : ( + + )} + +
+

Dieses Gerät

+

+ {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' && '…'} +

+
+ + {!pushMoeglich ? ( + Auf dem Server nicht eingerichtet + ) : pushZustand === 'an' ? ( + + ) : ( + pushZustand !== 'nicht-unterstuetzt' && + pushZustand !== 'verweigert' && ( + + ) + )} +
+ + {/* Einstellungen */} +
{ + ereignis.preventDefault() + const daten = new FormData(ereignis.currentTarget) + mitRueckmeldung(() => einstellungSpeichern(daten)) + }} + > +
+ Worüber + {ANLAESSE.map((anlass) => ( + + ))} +
+ +
+ Wie + + + + +
+ +
+
+ + +
+ +
+ + + {felder.zeitblockVorlaufMinuten && ( +

{felder.zeitblockVorlaufMinuten}

+ )} +
+ +
+ + +
+ +
+ + +
+
+ + + +

+ In der Ruhezeit bleibt alles still — bis auf den Tagesüberblick. Der käme sonst + nie, wenn du früh aufstehst. +

+ +
+ + +
+
+ + {/* Angemeldete Geräte */} + {geraete.length > 0 && ( +
+

Angemeldete Geräte

+
    + {geraete.map((geraet) => ( +
  • +
    +

    {geraet.bezeichnung ?? 'Unbenanntes Gerät'}

    +

    + Angemeldet {geraet.erstelltAm} + {geraet.zuletztBenutzt + ? ` · zuletzt benachrichtigt ${geraet.zuletztBenutzt}` + : ' · noch nichts empfangen'} +

    +
    + +
  • + ))} +
+
+ )} +
+ ) +} + +/** 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' +} diff --git a/src/app/(app)/einstellungen/page.tsx b/src/app/(app)/einstellungen/page.tsx index a47c835..c1514a4 100644 --- a/src/app/(app)/einstellungen/page.tsx +++ b/src/app/(app)/einstellungen/page.tsx @@ -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() { /> + + + + Benachrichtigungen + + 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. + + + + ({ + id: g.id, + bezeichnung: g.bezeichnung, + erstelltAm: alsDatumUhrzeit(g.erstelltAm), + zuletztBenutzt: g.zuletztBenutzt ? alsDatumUhrzeit(g.zuletztBenutzt) : null, + }))} + /> + + ) } diff --git a/src/server/aktionen/benachrichtigungen.ts b/src/server/aktionen/benachrichtigungen.ts new file mode 100644 index 0000000..ca7f71e --- /dev/null +++ b/src/server/aktionen/benachrichtigungen.ts @@ -0,0 +1,195 @@ +'use server' + +import { revalidatePath } from 'next/cache' +import { einstellungSchema } from '@/lib/benachrichtigung' +import { alsTagesschluessel } from '@/lib/zeit' +import { auditSchreiben } from '@/server/audit' +import { NichtErlaubt, pruefeRolle } from '@/server/berechtigung' +import { mailSenden } from '@/server/benachrichtigung/mail' +import { pushSenden } from '@/server/benachrichtigung/push' +import { alsEinstellung } from '@/server/benachrichtigung/versand' +import { db } from '@/server/db' +import { umgebung } from '@/server/umgebung' +import { jetzt } from '@/server/zeitgeber' + +export type Rueckmeldung = + | { ok: true; meldung: string } + | { ok: false; fehler: string; felder?: Record } + +async function akteurOderFehler() { + try { + return { akteur: await pruefeRolle('BENUTZER') } + } catch (fehler) { + if (fehler instanceof NichtErlaubt) { + return { antwort: { ok: false as const, fehler: fehler.message } } + } + throw fehler + } +} + +/** Speichert die Benachrichtigungseinstellung des angemeldeten Benutzers. */ +export async function einstellungSpeichern(daten: FormData): Promise { + const geprueft = await akteurOderFehler() + if ('antwort' in geprueft) return geprueft.antwort + const { akteur } = geprueft + + const gelesen = einstellungSchema.safeParse({ + anlaesse: daten.getAll('anlaesse'), + perPush: daten.get('perPush') !== null, + perMail: daten.get('perMail') !== null, + ueberblickUm: daten.get('ueberblickUm'), + ruheVon: daten.get('ruheVon'), + ruheBis: daten.get('ruheBis'), + ruheAmWochenende: daten.get('ruheAmWochenende') !== null, + zeitblockVorlaufMinuten: daten.get('zeitblockVorlaufMinuten'), + }) + + if (!gelesen.success) { + const felder: Record = {} + for (const problem of gelesen.error.issues) { + const feld = problem.path.join('.') + felder[feld] ??= problem.message + } + return { ok: false, fehler: 'Bitte die markierten Felder prüfen.', felder } + } + + const werte = { + anlaesse: gelesen.data.anlaesse.join(','), + perPush: gelesen.data.perPush, + perMail: gelesen.data.perMail, + ueberblickUm: gelesen.data.ueberblickUm, + ruheVon: gelesen.data.ruheVon, + ruheBis: gelesen.data.ruheBis, + ruheAmWochenende: gelesen.data.ruheAmWochenende, + zeitblockVorlaufMinuten: gelesen.data.zeitblockVorlaufMinuten, + } + + await db.benachrichtigungseinstellung.upsert({ + where: { benutzerId: akteur.id }, + update: werte, + create: { benutzerId: akteur.id, ...werte }, + }) + + await auditSchreiben({ + akteur: { id: akteur.id, email: akteur.email }, + aktion: 'benachrichtigung.geaendert', + objektTyp: 'Benachrichtigungseinstellung', + objektId: akteur.id, + details: { + anlaesse: werte.anlaesse, + perPush: werte.perPush, + perMail: werte.perMail, + }, + }) + + revalidatePath('/einstellungen') + return { ok: true, meldung: 'Einstellungen gespeichert.' } +} + +/** + * Schickt eine Probemeldung über alle eingeschalteten Wege. + * + * Sie umgeht Ruhezeit und Wiederholungssperre bewusst — wer auf den Knopf + * drückt, will jetzt wissen, ob es ankommt. + */ +export async function probeSenden(): Promise { + const geprueft = await akteurOderFehler() + if ('antwort' in geprueft) return geprueft.antwort + const { akteur } = geprueft + + const zeile = await db.benachrichtigungseinstellung.findUnique({ + where: { benutzerId: akteur.id }, + select: { + anlaesse: true, + perPush: true, + perMail: true, + ueberblickUm: true, + ruheVon: true, + ruheBis: true, + ruheAmWochenende: true, + zeitblockVorlaufMinuten: true, + }, + }) + const einstellung = alsEinstellung(zeile) + + if (!einstellung.perPush && !einstellung.perMail) { + return { ok: false, fehler: 'Erst einen Weg einschalten — Push oder E-Mail.' } + } + + const meldung = { + anlass: 'tagesueberblick' as const, + // Die Probe umgeht Sperre und Ruhezeit; der Schlüssel dient nur der Form. + schluessel: `probe:${akteur.id}:${alsTagesschluessel(jetzt())}`, + titel: 'Probemeldung', + text: 'Wenn du das liest, kommen Benachrichtigungen an.', + pfad: '/einstellungen', + } + + const wege: string[] = [] + const einst = umgebung() + + if (einstellung.perPush) { + if (!einst.pushAktiv) { + wege.push('Push ist auf diesem Server nicht eingerichtet') + } else { + const bericht = await pushSenden(akteur.id, meldung) + wege.push( + bericht.zugestellt > 0 + ? `Push an ${bericht.zugestellt} Gerät(e)` + : 'kein Gerät für Push angemeldet', + ) + } + } + + if (einstellung.perMail) { + if (!einst.mailAktiv) { + wege.push('E-Mail ist auf diesem Server nicht eingerichtet') + } else { + wege.push( + (await mailSenden(akteur.email, meldung)) + ? 'E-Mail verschickt' + : 'E-Mail fehlgeschlagen', + ) + } + } + + await auditSchreiben({ + akteur: { id: akteur.id, email: akteur.email }, + aktion: 'benachrichtigung.probe', + objektTyp: 'Benachrichtigungseinstellung', + objektId: akteur.id, + }) + + return { ok: true, meldung: wege.join(' · ') } +} + +/** Entfernt ein angemeldetes Push-Gerät. */ +export async function geraetEntfernen(daten: FormData): Promise { + const geprueft = await akteurOderFehler() + if ('antwort' in geprueft) return geprueft.antwort + const { akteur } = geprueft + + const id = daten.get('id') + if (typeof id !== 'string' || id.length === 0) { + return { ok: false, fehler: 'Ungültige Anfrage.' } + } + + const geraet = await db.pushGeraet.findFirst({ + where: { id, benutzerId: akteur.id }, + select: { id: true, bezeichnung: true }, + }) + if (!geraet) return { ok: false, fehler: 'Dieses Gerät gibt es nicht.' } + + await db.pushGeraet.delete({ where: { id: geraet.id } }) + + await auditSchreiben({ + akteur: { id: akteur.id, email: akteur.email }, + aktion: 'push.abgemeldet', + objektTyp: 'PushGeraet', + objektId: geraet.id, + details: { bezeichnung: geraet.bezeichnung }, + }) + + revalidatePath('/einstellungen') + return { ok: true, meldung: 'Gerät abgemeldet.' } +}