mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-24 16:03:02 +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 }
|
||||
}
|
||||
Reference in New Issue
Block a user