feat(notifications): Web Push delivers notifications to every device, even with TREK closed

A fourth built-in channel next to email, webhook and ntfy. Browsers
register per device, the server encrypts per RFC 8291 and signs per RFC
8292 with node:crypto and sends only to known push services through the
SSRF guard. The event matrix decides per category what arrives by push;
admins switch the channel on under Notifications.

VAPID keys come from the environment or are generated once, only while
neither stored row exists, and kept encrypted. A pair that does not
decrypt or match is never overwritten and never swapped for another one:
push stays off with an error in the log and every device stays
registered until the key is fixed. The subject is VAPID_SUBJECT, then an
https APP_URL, then the project URL.

Only chat messages collapse into one notification per trip, and a
replacement still alerts. A 403 removes a device only after three
failures in a row; 404 and 410 remove it at once. Password change, reset
and an admin setting a password drop the user's devices with the other
credentials. The request log redacts subscriptions and endpoints, boot
errors print *** for every secret variable, and logout forgets the
device with a plain fetch so an expired session cannot redirect midway.

Addresses discussion #894.
This commit is contained in:
Maurice
2026-09-24 23:17:03 +00:00
parent 7ce31756a3
commit 650451bdf7
177 changed files with 7809 additions and 271 deletions
@@ -0,0 +1,103 @@
/**
* FE-ADMCHAN-001 to FE-ADMCHAN-008: the admin's channel switches, shared by
* the desktop Notifications tab and the phone section.
*
* Every switch rewrites one comma-separated app setting. The two things that
* must never happen are the ones a rebuild-from-booleans used to do: drop an id
* this build does not know (a plugin channel, or a newer built-in), and leave
* the screen claiming a state the server refused to store.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { act, renderHook } from '@testing-library/react'
import { useState } from 'react'
import {
BUILTIN_CHANNEL_ORDER,
parseChannelList,
serializeChannelList,
SWITCH_ONLY_CHANNELS,
toggleChannelList,
useNotificationChannels,
} from './useNotificationChannels'
const updateAppSettings = vi.hoisted(() => vi.fn())
vi.mock('../../api/client', () => ({ authApi: { updateAppSettings } }))
const t = (key: string) => (key === 'common.error' ? 'Error' : key)
function mount(initial: Record<string, string>) {
const toast = { error: vi.fn() }
const hook = renderHook(() => {
const [smtpValues, setSmtpValues] = useState(initial)
return { smtpValues, channels: useNotificationChannels({ smtpValues, setSmtpValues, toast }, t) }
})
return { hook, toast }
}
beforeEach(() => {
updateAppSettings.mockReset()
updateAppSettings.mockResolvedValue({})
})
describe('the stored list', () => {
it('FE-ADMCHAN-001: reads the plural setting, the legacy singular one, and none', () => {
expect(parseChannelList({ notification_channels: 'email, push ,plugin-gotify' })).toEqual(['email', 'push', 'plugin-gotify'])
expect(parseChannelList({ notification_channel: 'webhook' })).toEqual(['webhook'])
expect(parseChannelList({ notification_channels: 'none' })).toEqual([])
expect(parseChannelList({})).toEqual([])
expect(parseChannelList({ notification_channels: 'email,' })).toEqual(['email'])
})
it('FE-ADMCHAN-002: writes built-ins in their fixed order and keeps every other id', () => {
expect(serializeChannelList(['plugin-gotify', 'push', 'email'])).toBe('email,push,plugin-gotify')
expect(serializeChannelList([])).toBe('none')
})
it('FE-ADMCHAN-003: flips exactly one id', () => {
expect(toggleChannelList(['email', 'plugin-gotify'], 'push')).toBe('email,push,plugin-gotify')
expect(toggleChannelList(['email', 'push'], 'push')).toBe('email')
expect(toggleChannelList(['push'], 'push')).toBe('none')
})
it('FE-ADMCHAN-004: Web Push is a built-in with its own switch card', () => {
expect(BUILTIN_CHANNEL_ORDER).toEqual(['email', 'webhook', 'ntfy', 'push'])
expect(SWITCH_ONLY_CHANNELS.map(c => c.id)).toEqual(['webhook', 'ntfy', 'push'])
expect(SWITCH_ONLY_CHANNELS[2]).toEqual({
id: 'push',
titleKey: 'admin.notifications.webPushPanel.title',
hintKey: 'admin.notifications.webPushPanel.hint',
})
})
})
describe('useNotificationChannels', () => {
it('FE-ADMCHAN-005: answers which channels are on', () => {
const { hook } = mount({ notification_channels: 'email,push' })
expect(hook.result.current.channels.isActive('push')).toBe(true)
expect(hook.result.current.channels.isActive('ntfy')).toBe(false)
})
it('FE-ADMCHAN-006: switching one on saves the whole list and shows it at once', async () => {
const { hook, toast } = mount({ notification_channels: 'email,plugin-gotify' })
await act(() => hook.result.current.channels.toggle('push'))
expect(updateAppSettings).toHaveBeenCalledWith({ notification_channels: 'email,push,plugin-gotify' })
expect(hook.result.current.smtpValues.notification_channels).toBe('email,push,plugin-gotify')
expect(toast.error).not.toHaveBeenCalled()
})
it('FE-ADMCHAN-007: a failed save puts the previous list back and says so', async () => {
updateAppSettings.mockRejectedValueOnce(new Error('500'))
const { hook, toast } = mount({ notification_channels: 'email' })
await act(() => hook.result.current.channels.toggle('push'))
expect(updateAppSettings).toHaveBeenCalledWith({ notification_channels: 'email,push' })
expect(hook.result.current.smtpValues.notification_channels).toBe('email')
expect(hook.result.current.channels.isActive('push')).toBe(false)
expect(toast.error).toHaveBeenCalledWith('Error')
})
it('FE-ADMCHAN-008: the rollback writes the normalised form, never an id less', async () => {
updateAppSettings.mockRejectedValueOnce(new Error('500'))
const { hook } = mount({ notification_channels: 'plugin-gotify, email' })
await act(() => hook.result.current.channels.toggle('webhook'))
expect(hook.result.current.smtpValues.notification_channels).toBe('email,plugin-gotify')
})
})
@@ -0,0 +1,88 @@
import type { Dispatch, SetStateAction } from 'react'
import { WEB_PUSH_CHANNEL_ID } from '@trek/shared'
import { authApi } from '../../api/client'
import type { TranslationFn } from '../../types'
/**
* The admin's channel switches, shared by the desktop Notifications tab and its
* phone twin. Both used to carry their own copy of a three-boolean
* `setChannels`, and a fourth channel would have meant a fourth argument in
* two places; SonarCloud's duplication budget has no room for that either.
*
* All switches write one app setting, `notification_channels`, a comma
* separated list of channel ids. Ids this build does not know (a plugin
* channel, or one a newer server added) must survive a toggle untouched, so
* the list is edited, never rebuilt from the switches.
*/
/** The built-in channels, in the order they are written to the list. Other ids follow as stored. */
export const BUILTIN_CHANNEL_ORDER: readonly string[] = ['email', 'webhook', 'ntfy', WEB_PUSH_CHANNEL_ID]
/** The channels whose admin card is only a title, a hint and the switch (email carries the SMTP form). */
export const SWITCH_ONLY_CHANNELS: ReadonlyArray<{ id: string; titleKey: string; hintKey: string }> = [
{ id: 'webhook', titleKey: 'admin.notifications.webhookPanel.title', hintKey: 'admin.webhook.hint' },
{ id: 'ntfy', titleKey: 'admin.notifications.ntfy', hintKey: 'admin.ntfy.hint' },
{
id: WEB_PUSH_CHANNEL_ID,
titleKey: 'admin.notifications.webPushPanel.title',
hintKey: 'admin.notifications.webPushPanel.hint',
},
]
/**
* The active ids, from `notification_channels` or, on installs that predate
* it, the singular `notification_channel`. 'none' is the stored empty list.
*/
export function parseChannelList(values: Record<string, string>): string[] {
const raw = values.notification_channels ?? values.notification_channel ?? 'none'
if (raw === 'none') return []
return raw
.split(',')
.map(id => id.trim())
.filter(Boolean)
}
/** The stored form: built-ins in their fixed order, then every other id as it was, or 'none'. */
export function serializeChannelList(active: readonly string[]): string {
const builtins = BUILTIN_CHANNEL_ORDER.filter(id => active.includes(id))
const others = active.filter(id => !BUILTIN_CHANNEL_ORDER.includes(id))
return [...builtins, ...others].join(',') || 'none'
}
/** The stored list with `id` switched the other way. */
export function toggleChannelList(active: readonly string[], id: string): string {
return serializeChannelList(active.includes(id) ? active.filter(c => c !== id) : [...active, id])
}
interface ChannelSettingsHost {
smtpValues: Record<string, string>
setSmtpValues: Dispatch<SetStateAction<Record<string, string>>>
toast: { error: (message: string) => void }
}
export interface NotificationChannelSwitches {
isActive: (id: string) => boolean
/** Flips one channel and saves the list; puts the old list back and toasts when the save fails. */
toggle: (id: string) => Promise<void>
}
export function useNotificationChannels(
{ smtpValues, setSmtpValues, toast }: ChannelSettingsHost,
t: TranslationFn,
): NotificationChannelSwitches {
const active = parseChannelList(smtpValues)
const toggle = async (id: string) => {
const next = toggleChannelList(active, id)
setSmtpValues(prev => ({ ...prev, notification_channels: next }))
try {
await authApi.updateAppSettings({ notification_channels: next })
} catch {
const reverted = serializeChannelList(active)
setSmtpValues(prev => ({ ...prev, notification_channels: reverted }))
toast.error(t('common.error'))
}
}
return { isActive: id => active.includes(id), toggle }
}
@@ -1185,3 +1185,47 @@ describe('NotificationsTab — webhook and channel-test failures', () => {
expect(await screen.findByText('Test failed.')).toBeInTheDocument();
});
});
// ─────────────────────────────────────────────────────────────────────────────
// Web Push (#894)
// ─────────────────────────────────────────────────────────────────────────────
//
// The Push column comes from the server like every other; what this tab adds is
// the per-device card, shown only while the admin has the channel switched on.
// jsdom has no push APIs, so the real hook reads this "browser" as unable to
// receive push, which is exactly what the card must then say.
const pushMatrix = (active: boolean) => ({
preferences: { trip_invite: { inapp: true, push: true } },
channels: [
{ id: 'inapp', source: 'builtin', labelKey: 'settings.notificationPreferences.inapp', active: true, configured: true },
{ id: 'push', source: 'builtin', labelKey: 'settings.notificationPreferences.push', active, configured: false },
],
event_types: ['trip_invite'],
implemented_combos: { trip_invite: ['inapp', 'push'] },
});
describe('NotificationsTab: Web Push', () => {
beforeEach(() => {
resetAllStores();
seedStore(useAuthStore, { isAuthenticated: true, user: buildUser() });
});
it('FE-COMP-NOTIFICATIONS-PUSH-001: an active push channel gets its column and the device card', async () => {
mockMatrix(pushMatrix(true));
render(<NotificationsTab />);
expect(await screen.findByText('Push notifications on this device')).toBeInTheDocument();
expect(screen.getByText('Push')).toBeInTheDocument();
expect(await screen.findByRole('status')).toHaveTextContent(/push/i);
});
it('FE-COMP-NOTIFICATIONS-PUSH-002: no card and no column while the admin has push off', async () => {
mockMatrix(pushMatrix(false));
render(<NotificationsTab />);
await screen.findByText(/in-app/i);
expect(screen.queryByText('Push notifications on this device')).not.toBeInTheDocument();
expect(screen.queryByText('Push')).not.toBeInTheDocument();
});
});
@@ -1,11 +1,13 @@
import React, { useState, useEffect } from 'react'
import { Link } from 'react-router'
import { Lock } from 'lucide-react'
import { WEB_PUSH_CHANNEL_ID } from '@trek/shared'
import { useTranslation } from '../../i18n'
import { notificationsApi, settingsApi } from '../../api/client'
import { useToast } from '../shared/Toast'
import ToggleSwitch from './ToggleSwitch'
import Section from './Section'
import WebPushCard from './WebPushCard'
interface ChannelDescriptor {
id: string
@@ -323,6 +325,9 @@ export default function NotificationsTab(): React.ReactElement {
</div>
</div>
)}
{/* Per device rather than per account: the column below picks the events,
this card decides whether this browser is one of the places they go. */}
{hasChannel(WEB_PUSH_CHANNEL_ID) && <WebPushCard />}
{pluginChannels.map(ch => (
<div key={ch.id} style={{ marginBottom: 16, padding: '12px', background: 'var(--bg-secondary)', borderRadius: 8, border: '1px solid var(--border-primary)' }}>
<label style={{ display: 'block', fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontWeight: 600, color: 'var(--text-secondary)', marginBottom: 4 }}>
@@ -0,0 +1,72 @@
/**
* FE-COMP-WEBPUSHCARD-001 to FE-COMP-WEBPUSHCARD-004: the desktop device card.
*
* Markup only: `useWebPush` is mocked and has its own spec, so these cases pin
* what each hook state puts on screen and that the buttons reach the hook.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import userEvent from '@testing-library/user-event'
import { render, screen } from '../../../tests/helpers/render'
import type { WebPushCardState } from '../../hooks/useWebPush'
import WebPushCard from './WebPushCard'
const hookState = vi.hoisted(() => ({ current: null as unknown as WebPushCardState }))
vi.mock('../../hooks/useWebPush', () => ({ useWebPush: () => hookState.current }))
function state(over: Partial<WebPushCardState> = {}): WebPushCardState {
return {
state: 'off',
stateText: null,
canSwitch: true,
on: false,
switchLabel: 'Turn on for this device',
busy: false,
canTest: false,
toggle: vi.fn(),
sendTest: vi.fn(),
...over,
}
}
beforeEach(() => {
hookState.current = state()
})
describe('WebPushCard', () => {
it('FE-COMP-WEBPUSHCARD-001: explains itself and offers to switch on', async () => {
render(<WebPushCard />)
expect(screen.getByText('Push notifications on this device')).toBeInTheDocument()
expect(screen.getByText(/Shows TREK notifications on this device/)).toBeInTheDocument()
expect(screen.queryByRole('status')).not.toBeInTheDocument()
const turnOn = screen.getByRole('button', { name: 'Turn on for this device' })
expect(turnOn.className).toContain('bg-accent')
expect(screen.getByRole('button', { name: 'Send test' })).toBeDisabled()
await userEvent.click(turnOn)
expect(hookState.current.toggle).toHaveBeenCalledTimes(1)
})
it('FE-COMP-WEBPUSHCARD-002: when on, says so, offers to switch off and to test', async () => {
hookState.current = state({ state: 'on', on: true, stateText: 'On for this device', switchLabel: 'Turn off for this device', canTest: true })
render(<WebPushCard />)
expect(screen.getByRole('status')).toHaveTextContent('On for this device')
expect(screen.getByRole('button', { name: 'Turn off for this device' }).className).not.toContain('bg-accent')
await userEvent.click(screen.getByRole('button', { name: 'Send test' }))
expect(hookState.current.sendTest).toHaveBeenCalledTimes(1)
})
it('FE-COMP-WEBPUSHCARD-003: a state that cannot be switched shows its sentence and no buttons', () => {
hookState.current = state({ state: 'ios-install', canSwitch: false, stateText: 'Add TREK to the Home Screen first.' })
render(<WebPushCard />)
expect(screen.getByRole('status')).toHaveTextContent('Add TREK to the Home Screen first.')
expect(screen.queryByRole('button')).not.toBeInTheDocument()
})
it('FE-COMP-WEBPUSHCARD-004: the switch is disabled while busy', () => {
hookState.current = state({ busy: true })
render(<WebPushCard />)
expect(screen.getByRole('button', { name: 'Turn on for this device' })).toBeDisabled()
})
})
@@ -0,0 +1,47 @@
import React from 'react'
import { useTranslation } from '../../i18n'
import { useWebPush } from '../../hooks/useWebPush'
/**
* The "Push notifications on this device" card of the Notifications tab. Only
* markup: the logic is `useWebPush`, shared with the phone's `MWebPushCard`.
* The tab renders it while the admin has the push channel switched on.
*/
export default function WebPushCard(): React.ReactElement {
const { t } = useTranslation()
const push = useWebPush()
return (
<div className="mb-4 rounded-lg border border-edge bg-surface-secondary p-3">
<p className="mb-1 text-caption font-semibold text-content-secondary">{t('settings.webPush.title')}</p>
<p className="mb-2 text-caption text-content-faint">{t('settings.webPush.hint')}</p>
{push.stateText && (
<p role="status" className="mb-2 text-caption text-content-secondary">
{push.stateText}
</p>
)}
{push.canSwitch && (
<div className="flex items-center gap-2">
<button
type="button"
onClick={push.toggle}
disabled={push.busy}
className={`rounded-md px-3 py-1.5 text-caption disabled:cursor-not-allowed disabled:opacity-60 ${
push.on ? 'border border-edge bg-transparent text-content-secondary' : 'bg-accent text-accent-text'
}`}
>
{push.switchLabel}
</button>
<button
type="button"
onClick={push.sendTest}
disabled={!push.canTest}
className="rounded-md border border-edge bg-transparent px-3 py-1.5 text-caption text-content-secondary disabled:cursor-not-allowed disabled:opacity-50"
>
{t('settings.notificationPreferences.sendTest')}
</button>
</div>
)}
</div>
)
}