mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-23 23:46:35 +00:00
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:
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user