feat(notify): web push as a notification channel
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 2m15s

- VAPID keys from VAPID_* or generated once (push_keys, migration 9);
  devices in push_subscriptions, forgotten on 404/410 from the push service
- push column for every event (also due tasks) and the daily summary,
  bundles as one notification; /push/key, /devices, /subscribe,
  /unsubscribe, /test
- settings: switch push on per device, test it, see the devices; the
  service worker shows notifications and focuses MindSift on click
- tests decrypt a sent push like a browser (RFC 8291) and check VAPID

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
sandrodachandClaude Opus 5.5 committed 2026-10-30 21:21:55 +01:00
1 parent 1a49e6b304
commit 38b8c92899
27 files changed
+918 -27

No files matched your search

@@ -1,4 +1,4 @@
import React, { useEffect, useState, useRef } from 'react';
import React, { useCallback, useEffect, useState, useRef } from 'react';
import {
User as UserIcon,
Bell,
@@ -17,7 +17,7 @@ import {
Pencil,
Copy,
} from 'lucide-react';
import { AuthStatus, NotificationEvents, NotificationSettings, User, UserTheme } from '../../lib/types';
import { AuthStatus, NotificationEvents, NotificationSettings, PushDevice, User, UserTheme } from '../../lib/types';
import { ApiKeysPanel } from './ApiKeysPanel';
import { ServerBackupsCard } from './ServerBackupsCard';
import { saveBlob } from '../../lib/download';
@@ -33,6 +33,7 @@ import { SessionsPanel } from './SessionsPanel';
import { ToggleSwitch } from '../../components/ToggleSwitch';
import { setLinkPreviewsEnabled, useLinkPreviewsEnabled } from '../../lib/linkPreview';
import { isJournalEnabled, setJournalEnabled } from '../../lib/journal';
import { currentPushSubscription, disablePush, enablePush, pushSupported } from '../../lib/push';
import { FormSheet } from '../../components/FormSheet';
interface UserSettingsViewProps {
@@ -62,21 +63,25 @@ export function buildNotificationSettings(user: User): NotificationSettings {
email: s?.events?.due?.email ?? dueEmailDefault,
discord: s?.events?.due?.discord ?? dueDiscordDefault,
webhook: s?.events?.due?.webhook ?? dueWebhookDefault,
push: s?.events?.due?.push ?? false,
},
shared: {
email: s?.events?.shared?.email ?? true,
discord: s?.events?.shared?.discord ?? false,
webhook: s?.events?.shared?.webhook ?? false,
push: s?.events?.shared?.push ?? false,
},
taskDone: {
email: s?.events?.taskDone?.email ?? false,
discord: s?.events?.taskDone?.discord ?? false,
webhook: s?.events?.taskDone?.webhook ?? false,
push: s?.events?.taskDone?.push ?? false,
},
watched: {
email: s?.events?.watched?.email ?? true,
discord: s?.events?.watched?.discord ?? true,
webhook: s?.events?.watched?.webhook ?? false,
push: s?.events?.watched?.push ?? false,
},
},
dailySummary: {
@@ -85,6 +90,7 @@ export function buildNotificationSettings(user: User): NotificationSettings {
email: s?.dailySummary?.email ?? true,
discord: s?.dailySummary?.discord ?? false,
webhook: s?.dailySummary?.webhook ?? false,
push: s?.dailySummary?.push ?? false,
},
};
}
@@ -271,6 +277,46 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
const [savingAlarms, setSavingAlarms] = useState(false);
const [testingChannel, setTestingChannel] = useState<string | null>(null);
// Web push: devices of this account and whether this browser is one of them
const [pushDevices, setPushDevices] = useState<PushDevice[]>([]);
const [pushHere, setPushHere] = useState(false);
const [pushBusy, setPushBusy] = useState(false);
const [pushMsg, setPushMsg] = useState<{ type: 'ok' | 'err'; text: string } | null>(null);
const reloadPush = useCallback(async () => {
const [devices, sub] = await Promise.all([api.getPushDevices().catch(() => []), currentPushSubscription()]);
setPushDevices(devices);
setPushHere(Boolean(sub && devices.some((d) => d.endpoint === sub.endpoint)));
}, []);
useEffect(() => {
void reloadPush();
}, [reloadPush]);
const togglePushHere = async (on: boolean) => {
setPushBusy(true);
setPushMsg(null);
try {
if (on) await enablePush();
else await disablePush();
await reloadPush();
setPushMsg({ type: 'ok', text: on ? 'Push ist auf diesem Gerät eingeschaltet.' : 'Push ist auf diesem Gerät ausgeschaltet.' });
} catch (err) {
setPushMsg({ type: 'err', text: err instanceof Error ? err.message : 'Push konnte nicht eingeschaltet werden' });
} finally {
setPushBusy(false);
}
};
const sendTestPush = async () => {
setPushBusy(true);
setPushMsg(null);
try {
const res = await api.testPush();
setPushMsg({ type: 'ok', text: `Test-Benachrichtigung an ${res.devices} ${res.devices === 1 ? 'Gerät' : 'Geräte'} gesendet.` });
} catch (err) {
setPushMsg({ type: 'err', text: err instanceof Error ? err.message : 'Test fehlgeschlagen' });
} finally {
setPushBusy(false);
}
};
useEffect(() => {
setDisplayName(currentUser.displayName || '');
setEmail(currentUser.email || '');
@@ -283,7 +329,7 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
}, [currentUser]);
const setEventChannel = (eventKey: keyof NotificationEvents, channel: 'email' | 'discord' | 'webhook', value: boolean) => {
const setEventChannel = (eventKey: keyof NotificationEvents, channel: 'email' | 'discord' | 'webhook' | 'push', value: boolean) => {
setNotificationSettings((prev) => ({
...prev,
events: {
@@ -1140,23 +1186,28 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
{(() => {
const hasDiscordWebhook = Boolean(discordWebhookUrl.trim());
const hasCustomWebhook = Boolean(customWebhookUrl.trim());
const hasPush = pushDevices.length > 0;
return (
<>
<div className="p-4 bg-canvas border border-border space-y-3">
<div className="grid grid-cols-[1fr_auto_auto_auto] items-center gap-2 sm:gap-4 pb-2 border-b border-border">
<div className="grid grid-cols-[1fr_auto_auto_auto_auto] items-center gap-1 sm:gap-4 pb-2 border-b border-border">
<span className="font-bold text-main">Ereignis</span>
<span className="w-16 text-center font-semibold text-muted flex items-center justify-center gap-1">
<span className="w-12 sm:w-16 text-center font-semibold text-muted flex flex-col sm:flex-row items-center justify-center gap-0.5 sm:gap-1">
<Mail className="w-3.5 h-3.5 text-accent" />
<span>E-Mail</span>
</span>
<span className="w-16 text-center font-semibold text-muted flex items-center justify-center gap-1">
<span className="w-12 sm:w-16 text-center font-semibold text-muted flex flex-col sm:flex-row items-center justify-center gap-0.5 sm:gap-1">
<MessageSquare className="w-3.5 h-3.5 text-accent" />
<span>Discord</span>
</span>
<span className="w-16 text-center font-semibold text-muted flex items-center justify-center gap-1">
<span className="w-12 sm:w-16 text-center font-semibold text-muted flex flex-col sm:flex-row items-center justify-center gap-0.5 sm:gap-1">
<Globe className="w-3.5 h-3.5 text-accent" />
<span>Webhook</span>
</span>
<span className="w-12 sm:w-16 text-center font-semibold text-muted flex flex-col sm:flex-row items-center justify-center gap-0.5 sm:gap-1">
<Bell className="w-3.5 h-3.5 text-accent" />
<span>Push</span>
</span>
</div>
<div className="divide-y divide-border">
@@ -1165,17 +1216,17 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
return (
<div
key={row.key}
className="grid grid-cols-[1fr_auto_auto_auto] items-center gap-2 sm:gap-4 py-2.5"
className="grid grid-cols-[1fr_auto_auto_auto_auto] items-center gap-1 sm:gap-4 py-2.5"
>
<span className="font-medium text-main">{row.label}</span>
<div className="w-16 flex justify-center">
<div className="w-12 sm:w-16 flex justify-center">
<ToggleSwitch
checked={routing.email}
onChange={(v) => setEventChannel(row.key, 'email', v)}
title={`${row.label} – E-Mail`}
/>
</div>
<div className="w-16 flex justify-center">
<div className="w-12 sm:w-16 flex justify-center">
<ToggleSwitch
checked={routing.discord}
disabled={!hasDiscordWebhook}
@@ -1187,7 +1238,7 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
}
/>
</div>
<div className="w-16 flex justify-center">
<div className="w-12 sm:w-16 flex justify-center">
<ToggleSwitch
checked={routing.webhook}
disabled={!hasCustomWebhook}
@@ -1199,6 +1250,14 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
}
/>
</div>
<div className="w-12 sm:w-16 flex justify-center">
<ToggleSwitch
checked={routing.push}
disabled={!hasPush}
onChange={(v) => setEventChannel(row.key, 'push', v)}
title={hasPush ? `${row.label} – Push` : 'Bitte zuerst unten Push auf einem Gerät einschalten'}
/>
</div>
</div>
);
})}
@@ -1307,6 +1366,20 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
}
/>
</div>
<div className="flex items-center gap-2">
<span className="text-xs text-muted">Push</span>
<ToggleSwitch
checked={notificationSettings.dailySummary.push}
disabled={!hasPush}
onChange={(v) =>
setNotificationSettings((prev) => ({
...prev,
dailySummary: { ...prev.dailySummary, push: v },
}))
}
title={hasPush ? 'Tageszusammenfassung – Push' : 'Bitte zuerst unten Push auf einem Gerät einschalten'}
/>
</div>
</div>
</div>
</div>
@@ -1458,6 +1531,49 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
</div>
</div>
{/* Web push: this browser or installed app */}
<div className={`p-4 border transition-colors space-y-3 bg-canvas ${pushHere ? 'border-accent' : 'border-border'}`}>
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="flex items-center gap-2.5 min-w-0">
<Bell className="w-4 h-4 text-accent shrink-0" />
<div className="min-w-0">
<span className="font-bold text-main block">Ziel: Push-Benachrichtigungen</span>
<span className="text-[11px] text-muted">
Systembenachrichtigung auf diesem Gerät, auch wenn MindSift geschlossen ist. Auf iPhone und
iPad nur, wenn MindSift zum Home-Bildschirm hinzugefügt wurde.
</span>
</div>
</div>
<div className="flex items-center gap-2 shrink-0">
<span className="text-xs text-muted">Dieses Gerät</span>
<ToggleSwitch
checked={pushHere}
disabled={pushBusy || demoMode || !pushSupported()}
onChange={(v) => void togglePushHere(v)}
title={pushSupported() ? 'Push auf diesem Gerät' : 'Dieser Browser unterstützt keine Push-Benachrichtigungen'}
/>
<button
type="button"
onClick={() => void sendTestPush()}
disabled={pushBusy || demoMode || pushDevices.length === 0}
className="btn-action h-8 px-3 text-xs flex items-center gap-1.5 disabled:opacity-40 disabled:cursor-not-allowed"
>
<Send className="w-3 h-3 text-accent" />
<span>Test senden</span>
</button>
</div>
</div>
{pushDevices.length > 0 && (
<p className="pl-6 text-[11px] text-faint">
Aktiv auf {pushDevices.length} {pushDevices.length === 1 ? 'Gerät' : 'Geräten'}:{' '}
{pushDevices.map((d) => d.device || 'Gerät').join(', ')}
</p>
)}
{pushMsg && (
<p className={`pl-6 text-[11px] ${pushMsg.type === 'ok' ? 'text-status-ok' : 'text-status-err'}`}>{pushMsg.text}</p>
)}
</div>
<div className="pt-2 border-t border-border flex justify-end">
<button
type="submit"
@@ -13,6 +13,7 @@ vi.mock('../../../lib/api', async () => {
...actual.api,
getMe: vi.fn(async () => ({ isLastAdmin: false })),
getAuthStatus: vi.fn(async () => ({ demoMode: false })),
getPushDevices: vi.fn(async () => []),
updateSettings: vi.fn(async (patch) => ({ ...baseUser, ...patch })),
},
};
@@ -88,10 +89,10 @@ describe('UserSettingsView notification matrix', () => {
paused: true,
includeOwnWatched: true,
events: {
due: { email: true, discord: true, webhook: false },
shared: { email: true, discord: false, webhook: false },
taskDone: { email: true, discord: false, webhook: false },
watched: { email: true, discord: false, webhook: false },
due: { email: true, discord: true, webhook: false, push: false },
shared: { email: true, discord: false, webhook: false, push: false },
taskDone: { email: true, discord: false, webhook: false, push: false },
watched: { email: true, discord: false, webhook: false, push: false },
},
dailySummary: {
enabled: true,
@@ -99,6 +100,7 @@ describe('UserSettingsView notification matrix', () => {
email: true,
discord: true,
webhook: false,
push: false,
},
},
})