mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
c9627af239
TREK's four channels (in-app, email, webhook, ntfy) were a closed set: notificationService.send() dispatched with four copy-pasted `if` blocks and no provider abstraction, so a fifth channel meant editing eight files by hand. A plugin could produce a notification via ctx.notify.send(), but never deliver one. A plugin now registers a channel with `hooks.notificationChannel` + `hook:notification-channel` on a plain `type: 'integration'` — not a new manifest type, so the TREK-Plugins registry schema and both its CI gates are untouched. Core refactor - New channel registry (services/notifications/): email/webhook/ntfy become ExternalChannel providers wrapping the EXISTING send functions — no delivery logic is rewritten, only relocated. In-app deliberately stays out: it writes typed rows with scope/target/callbacks, not a rendered title+body, the same line shared/ already draws with i18n/externalNotifications. - The event text is now rendered once per recipient instead of once per channel. - The channel set is open: NotifChannel becomes a string, the matrix is registry-derived, and the UI columns are server-driven. The DB column was already bare TEXT and the Zod contract already a string record — only the TypeScript and the two UIs were ever closed. The hook runs USERLESS. Every other hook is user-initiated, so actingUserId falls out of the request; a notification is host-initiated for an ARBITRARY recipient, so ctx.settings.get() would return undefined. The host resolves the recipient's decrypted scope:'user' settings itself and passes them as an argument. That is what lets a channel plugin be handed someone's push token WITHOUT being handed the right to read their trips as them. Enabling the plugin is the opt-in: a plugin channel is not gated on the admin's `notification_channels` list. A built-in always exists in code and needs an explicit switch; a plugin channel only exists because an admin enabled that plugin. (Nothing could write a `plugin:` id into that CSV anyway, and the admin toggle rebuilt it from three booleans, silently dropping anything else — so requiring a second opt-in meant the channel could never be turned on at all.) Also fixed, found while building this: - Plugin settings keys were unvalidated, so a field named `__proto__` or `constructor` resolved off Object.prototype: a REQUIRED field with such a name reported as configured for every user who had configured nothing — enough, for a channel, to be dispatched to everyone with no credentials. Keys are now constrained at install and the config blob is parsed null-prototype, so it is impossible even for an already-installed plugin. - A `select` field's options were cast straight through, so the obvious `["1","5"]` form rendered every dropdown entry BLANK (the client reads value/label). Now coerced, and malformed options are rejected. Also adds: operator-supplied egress hosts (a plugin talking to a self-hosted service can't name the operator's host at publish time, so an admin adds it post-install and the runtime re-spawns the child with the widened allow-list — only for a plugin that DECLARED operatorEgress, and only an admin, never a user); settings-page actions (a "Test connection" button, user-initiated so ctx.settings.get() returns the clicking user's own value); and a Gotify-shaped notification-channel template in the SDK. Verified end to end against a real Gotify container, not just in tests.
100 lines
5.2 KiB
TypeScript
100 lines
5.2 KiB
TypeScript
import React, { useEffect, useState } from 'react'
|
||
import { adminApi } from '../../api/client'
|
||
import { useToast } from '../../components/shared/Toast'
|
||
import { ADMIN_EVENT_LABEL_KEYS, ADMIN_CHANNEL_LABEL_KEYS } from './AdminPage.constants'
|
||
|
||
// Per-event × per-channel admin notification preference matrix.
|
||
// Loads its own data and auto-saves each toggle. Markup identical to AdminPage.
|
||
export default function AdminNotificationsPanel({ t, toast }: { t: (k: string) => string; toast: ReturnType<typeof useToast> }) {
|
||
const [matrix, setMatrix] = useState<any>(null)
|
||
const [saving, setSaving] = useState(false)
|
||
|
||
useEffect(() => {
|
||
adminApi.getNotificationPreferences().then((data: any) => setMatrix(data)).catch(() => {})
|
||
}, [])
|
||
|
||
if (!matrix) return <p className="text-content-faint" style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontStyle: 'italic', padding: 16 }}>Loading…</p>
|
||
|
||
// Admin-scoped events only ever go out over the built-in channels (plugin channels
|
||
// are user-scoped), so this list stays explicit rather than server-driven.
|
||
const isActive = (id: string) => matrix.channels?.some((c: { id: string; active: boolean }) => c.id === id && c.active) ?? false
|
||
const visibleChannels = (['inapp', 'email', 'webhook', 'ntfy'] as const).filter(ch => {
|
||
if (!isActive(ch)) return false
|
||
return matrix.event_types.some((evt: string) => matrix.implemented_combos[evt]?.includes(ch))
|
||
})
|
||
|
||
const toggle = async (eventType: string, channel: string) => {
|
||
const current = matrix.preferences[eventType]?.[channel] ?? true
|
||
const updated = { ...matrix.preferences, [eventType]: { ...matrix.preferences[eventType], [channel]: !current } }
|
||
setMatrix((m: any) => m ? { ...m, preferences: updated } : m)
|
||
setSaving(true)
|
||
try {
|
||
await adminApi.updateNotificationPreferences(updated)
|
||
} catch {
|
||
setMatrix((m: any) => m ? { ...m, preferences: matrix.preferences } : m)
|
||
toast.error(t('common.error'))
|
||
} finally {
|
||
setSaving(false)
|
||
}
|
||
}
|
||
|
||
if (matrix.event_types.length === 0) {
|
||
return (
|
||
<div className="bg-white rounded-xl border border-slate-200 p-6">
|
||
<p className="text-content-faint" style={{ fontSize: 'calc(13px * var(--fs-scale-body, 1))' }}>{t('settings.notificationPreferences.noChannels')}</p>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
return (
|
||
<div className="space-y-4">
|
||
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden">
|
||
<div className="px-6 py-4 border-b border-slate-100">
|
||
<h2 className="font-semibold text-slate-900">{t('admin.tabs.notifications')}</h2>
|
||
<p className="text-xs text-slate-400 mt-1">{t('admin.notifications.adminNotificationsHint')}</p>
|
||
</div>
|
||
<div className="p-6">
|
||
{saving && <p className="text-content-faint" style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', marginBottom: 8 }}>Saving…</p>}
|
||
{/* Header row */}
|
||
<div className="border-b border-edge" style={{ display: 'grid', gridTemplateColumns: `1fr ${visibleChannels.map(() => '80px').join(' ')}`, gap: 4, paddingBottom: 6, marginBottom: 4 }}>
|
||
<span />
|
||
{visibleChannels.map(ch => (
|
||
<span key={ch} className="text-content-faint" style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 600, textAlign: 'center', textTransform: 'uppercase', letterSpacing: '0.04em' }}>
|
||
{t(ADMIN_CHANNEL_LABEL_KEYS[ch]) || ch}
|
||
</span>
|
||
))}
|
||
</div>
|
||
{/* Event rows */}
|
||
{matrix.event_types.map((eventType: string) => {
|
||
const implementedForEvent = matrix.implemented_combos[eventType] ?? []
|
||
return (
|
||
<div key={eventType} className="border-b border-edge" style={{ display: 'grid', gridTemplateColumns: `1fr ${visibleChannels.map(() => '80px').join(' ')}`, gap: 4, alignItems: 'center', padding: '8px 0' }}>
|
||
<span className="text-content" style={{ fontSize: 'calc(13px * var(--fs-scale-body, 1))' }}>
|
||
{t(ADMIN_EVENT_LABEL_KEYS[eventType]) || eventType}
|
||
</span>
|
||
{visibleChannels.map(ch => {
|
||
if (!implementedForEvent.includes(ch)) {
|
||
return <span key={ch} className="text-content-faint" style={{ textAlign: 'center', fontSize: 'calc(14px * var(--fs-scale-body, 1))' }}>—</span>
|
||
}
|
||
const isOn = matrix.preferences[eventType]?.[ch] ?? true
|
||
return (
|
||
<div key={ch} style={{ display: 'flex', justifyContent: 'center' }}>
|
||
<button
|
||
onClick={() => toggle(eventType, ch)}
|
||
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors flex-shrink-0 ${isOn ? 'bg-content' : 'bg-edge'}`}
|
||
>
|
||
<span className="absolute left-0.5 h-4 w-4 rounded-full bg-white transition-transform duration-200"
|
||
style={{ transform: isOn ? 'translateX(16px)' : 'translateX(0)' }} />
|
||
</button>
|
||
</div>
|
||
)
|
||
})}
|
||
</div>
|
||
)
|
||
})}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|