diff --git a/client/src/api/client.ts b/client/src/api/client.ts index 38203ee2..48e6889d 100644 --- a/client/src/api/client.ts +++ b/client/src/api/client.ts @@ -486,6 +486,7 @@ export const notificationsApi = { updatePreferences: (prefs: Record>) => apiClient.put('/notifications/preferences', prefs).then(r => r.data), testSmtp: (email?: string) => apiClient.post('/notifications/test-smtp', { email }).then(r => r.data), testWebhook: (url?: string) => apiClient.post('/notifications/test-webhook', { url }).then(r => r.data), + testNtfy: (payload: { topic?: string; server?: string | null; token?: string | null }) => apiClient.post('/notifications/test-ntfy', payload).then(r => r.data), } export const inAppNotificationsApi = { diff --git a/client/src/components/Settings/NotificationsTab.test.tsx b/client/src/components/Settings/NotificationsTab.test.tsx index b3b808a4..d712d5ff 100644 --- a/client/src/components/Settings/NotificationsTab.test.tsx +++ b/client/src/components/Settings/NotificationsTab.test.tsx @@ -347,6 +347,99 @@ describe('NotificationsTab', () => { }); }); + it('FE-COMP-NOTIFICATIONS-ntfy-001: ntfy topic input renders when ntfy channel is available', async () => { + server.use( + http.get('/api/notifications/preferences', () => + HttpResponse.json({ + preferences: { trip_invite: { inapp: true, ntfy: false } }, + available_channels: { email: false, webhook: false, inapp: true, ntfy: true }, + event_types: ['trip_invite'], + implemented_combos: { trip_invite: ['inapp', 'ntfy'] }, + }), + ), + ); + + render(); + await waitFor(() => { + expect(screen.queryByText('Loading...')).not.toBeInTheDocument(); + }); + + // Ntfy topic input should be present (placeholder text from i18n key or EN default) + const inputs = await screen.findAllByRole('textbox'); + expect(inputs.length).toBeGreaterThan(0); + }); + + it('FE-COMP-NOTIFICATIONS-ntfy-002: ntfy test button disabled when no topic entered', async () => { + server.use( + http.get('/api/notifications/preferences', () => + HttpResponse.json({ + preferences: { trip_invite: { inapp: true, ntfy: false } }, + available_channels: { email: false, webhook: false, inapp: true, ntfy: true }, + event_types: ['trip_invite'], + implemented_combos: { trip_invite: ['inapp', 'ntfy'] }, + }), + ), + http.get('/api/settings', () => HttpResponse.json({ settings: { ntfy_topic: '' } })), + ); + + render(); + await waitFor(() => { + expect(screen.queryByText('Loading...')).not.toBeInTheDocument(); + }); + + // Test button should be disabled when topic is empty + const allButtons = await screen.findAllByRole('button'); + const testBtn = allButtons.find(b => /test/i.test(b.textContent || '')); + expect(testBtn).toBeDefined(); + expect(testBtn).toBeDisabled(); + }); + + it('FE-COMP-NOTIFICATIONS-ntfy-003: entering topic and clicking Test calls test-ntfy API', async () => { + const user = userEvent.setup(); + let ntfyCalled = false; + server.use( + http.get('/api/notifications/preferences', () => + HttpResponse.json({ + preferences: { trip_invite: { inapp: true, ntfy: false } }, + available_channels: { email: false, webhook: false, inapp: true, ntfy: true }, + event_types: ['trip_invite'], + implemented_combos: { trip_invite: ['inapp', 'ntfy'] }, + }), + ), + http.post('/api/notifications/test-ntfy', () => { + ntfyCalled = true; + return HttpResponse.json({ success: true }); + }), + ); + + render( + <> + + + , + ); + + await waitFor(() => { + expect(screen.queryByText('Loading...')).not.toBeInTheDocument(); + }); + + // Find the topic input (first textbox in the ntfy block) and type a topic + const inputs = await screen.findAllByRole('textbox'); + await user.type(inputs[0], 'my-test-topic'); + + // Test button should now be enabled + const allButtons = screen.getAllByRole('button'); + const testBtn = allButtons.find(b => /test/i.test(b.textContent || '')); + expect(testBtn).toBeDefined(); + expect(testBtn).not.toBeDisabled(); + + await user.click(testBtn!); + + await waitFor(() => { + expect(ntfyCalled).toBe(true); + }); + }); + it('FE-COMP-NOTIFICATIONS-014: failed test webhook shows error toast with message', async () => { const user = userEvent.setup(); server.use( diff --git a/client/src/components/Settings/NotificationsTab.tsx b/client/src/components/Settings/NotificationsTab.tsx index 2fa109eb..4978926b 100644 --- a/client/src/components/Settings/NotificationsTab.tsx +++ b/client/src/components/Settings/NotificationsTab.tsx @@ -8,7 +8,7 @@ import Section from './Section' interface PreferencesMatrix { preferences: Record> - available_channels: { email: boolean; webhook: boolean; inapp: boolean } + available_channels: { email: boolean; webhook: boolean; inapp: boolean; ntfy: boolean } event_types: string[] implemented_combos: Record } @@ -17,6 +17,7 @@ const CHANNEL_LABEL_KEYS: Record = { email: 'settings.notificationPreferences.email', webhook: 'settings.notificationPreferences.webhook', inapp: 'settings.notificationPreferences.inapp', + ntfy: 'settings.notificationPreferences.ntfy', } const EVENT_LABEL_KEYS: Record = { @@ -39,6 +40,12 @@ export default function NotificationsTab(): React.ReactElement { const [webhookIsSet, setWebhookIsSet] = useState(false) const [webhookSaving, setWebhookSaving] = useState(false) const [webhookTesting, setWebhookTesting] = useState(false) + const [ntfyTopic, setNtfyTopic] = useState('') + const [ntfyServer, setNtfyServer] = useState('') + const [ntfyToken, setNtfyToken] = useState('') + const [ntfyTokenIsSet, setNtfyTokenIsSet] = useState(false) + const [ntfySaving, setNtfySaving] = useState(false) + const [ntfyTesting, setNtfyTesting] = useState(false) useEffect(() => { notificationsApi.getPreferences().then((data: PreferencesMatrix) => setMatrix(data)).catch(() => {}) @@ -50,12 +57,21 @@ export default function NotificationsTab(): React.ReactElement { } else { setWebhookUrl(val) } + setNtfyTopic((data.settings?.ntfy_topic as string) || '') + setNtfyServer((data.settings?.ntfy_server as string) || '') + const rawToken = (data.settings?.ntfy_token as string) || '' + if (rawToken === '••••••••') { + setNtfyTokenIsSet(true) + setNtfyToken('') + } else { + setNtfyToken(rawToken) + } }).catch(() => {}) }, []) const visibleChannels = matrix - ? (['email', 'webhook', 'inapp'] as const).filter(ch => { - if (!matrix.available_channels[ch]) return false + ? (['email', 'webhook', 'ntfy', 'inapp'] as const).filter(ch => { + if (!matrix.available_channels[ch as keyof typeof matrix.available_channels]) return false return matrix.event_types.some(evt => matrix.implemented_combos[evt]?.includes(ch)) }) : [] @@ -106,6 +122,52 @@ export default function NotificationsTab(): React.ReactElement { } } + const saveNtfySettings = async () => { + setNtfySaving(true) + try { + await settingsApi.setBulk({ + ntfy_topic: ntfyTopic, + ntfy_server: ntfyServer, + ...(ntfyToken && ntfyToken !== '••••••••' ? { ntfy_token: ntfyToken } : {}), + }) + if (ntfyToken && ntfyToken !== '••••••••') setNtfyTokenIsSet(true) + toast.success(t('settings.ntfyUrl.saved')) + } catch { + toast.error(t('common.error')) + } finally { + setNtfySaving(false) + } + } + + const clearNtfyToken = async () => { + try { + await settingsApi.set('ntfy_token', '') + setNtfyToken('') + setNtfyTokenIsSet(false) + toast.success(t('settings.ntfyUrl.tokenCleared')) + } catch { + toast.error(t('common.error')) + } + } + + const testNtfySettings = async () => { + if (!ntfyTopic) return + setNtfyTesting(true) + try { + const result = await notificationsApi.testNtfy({ + topic: ntfyTopic, + server: ntfyServer || null, + token: ntfyToken && ntfyToken !== '••••••••' ? ntfyToken : null, + }) + if (result.success) toast.success(t('settings.ntfyUrl.testSuccess')) + else toast.error(result.error || t('settings.ntfyUrl.testFailed')) + } catch { + toast.error(t('settings.ntfyUrl.testFailed')) + } finally { + setNtfyTesting(false) + } + } + const renderContent = () => { if (!matrix) return

{t('common.loading')}

@@ -139,7 +201,7 @@ export default function NotificationsTab(): React.ReactElement { disabled={webhookSaving} style={{ fontSize: 12, padding: '6px 12px', background: 'var(--text-primary)', color: 'var(--bg-primary)', border: 'none', borderRadius: 6, cursor: webhookSaving ? 'not-allowed' : 'pointer', opacity: webhookSaving ? 0.6 : 1 }} > - {t('settings.webhookUrl.save')} + {t('common.save')} + )} + + + + + )} {/* Header row */}
'64px').join(' ')}`, gap: 4, paddingBottom: 6, marginBottom: 4, borderBottom: '1px solid var(--border-primary)' }}> diff --git a/client/src/i18n/translations/ar.ts b/client/src/i18n/translations/ar.ts index 92c23eec..271cc672 100644 --- a/client/src/i18n/translations/ar.ts +++ b/client/src/i18n/translations/ar.ts @@ -1809,14 +1809,27 @@ const ar: Record = { 'settings.webhookUrl.label': 'رابط Webhook', 'settings.webhookUrl.placeholder': 'https://discord.com/api/webhooks/...', 'settings.webhookUrl.hint': 'أدخل رابط Webhook الخاص بـ Discord أو Slack أو المخصص لتلقي الإشعارات.', - 'settings.webhookUrl.save': 'حفظ', 'settings.webhookUrl.saved': 'تم حفظ رابط Webhook', 'settings.webhookUrl.test': 'اختبار', 'settings.webhookUrl.testSuccess': 'تم إرسال Webhook الاختباري بنجاح', 'settings.webhookUrl.testFailed': 'فشل إرسال Webhook الاختباري', + 'settings.ntfyUrl.topicLabel': 'موضوع Ntfy', + 'settings.ntfyUrl.topicPlaceholder': 'my-trek-alerts', + 'settings.ntfyUrl.serverLabel': 'عنوان URL خادم Ntfy (اختياري)', + 'settings.ntfyUrl.serverPlaceholder': 'https://ntfy.sh', + 'settings.ntfyUrl.hint': 'أدخل موضوع Ntfy الخاص بك لتلقي الإشعارات الفورية. اترك حقل الخادم فارغاً لاستخدام الإعداد الافتراضي الذي حدده المسؤول.', + 'settings.ntfyUrl.tokenLabel': 'رمز الوصول (اختياري)', + 'settings.ntfyUrl.tokenHint': 'مطلوب للمواضيع المحمية بكلمة مرور.', + 'settings.ntfyUrl.saved': 'تم حفظ إعدادات Ntfy', + 'settings.ntfyUrl.test': 'اختبار', + 'settings.ntfyUrl.testSuccess': 'تم إرسال إشعار Ntfy التجريبي بنجاح', + 'settings.ntfyUrl.testFailed': 'فشل إشعار Ntfy التجريبي', + 'settings.ntfyUrl.clearToken': 'مسح', + 'settings.ntfyUrl.tokenCleared': 'تم مسح رمز الوصول', 'settings.notificationPreferences.inapp': 'In-App', 'settings.notificationPreferences.webhook': 'Webhook', 'settings.notificationPreferences.email': 'Email', + 'settings.notificationPreferences.ntfy': 'Ntfy', 'admin.notifications.emailPanel.title': 'Email (SMTP)', 'admin.notifications.webhookPanel.title': 'Webhook', 'admin.notifications.inappPanel.title': 'In-App', @@ -1827,6 +1840,22 @@ const ar: Record = { 'admin.notifications.adminWebhookPanel.testSuccess': 'تم إرسال Webhook الاختباري بنجاح', 'admin.notifications.adminWebhookPanel.testFailed': 'فشل إرسال Webhook الاختباري', 'admin.notifications.adminWebhookPanel.alwaysOnHint': 'يُرسل Webhook المسؤول تلقائيًا عند تعيين رابط URL', + 'admin.notifications.ntfy': 'Ntfy', + 'admin.notifications.testNtfy': 'إرسال Ntfy تجريبي', + 'admin.notifications.testNtfySuccess': 'تم إرسال Ntfy التجريبي بنجاح', + 'admin.notifications.testNtfyFailed': 'فشل إرسال Ntfy التجريبي', + 'admin.notifications.adminNtfyPanel.title': 'Ntfy المسؤول', + 'admin.notifications.adminNtfyPanel.hint': 'يُستخدم موضوع Ntfy هذا حصريًا لإشعارات المسؤول (مثل تنبيهات الإصدارات). وهو مستقل عن مواضيع المستخدمين ويُرسل دائمًا عند تهيئته.', + 'admin.notifications.adminNtfyPanel.serverLabel': 'عنوان URL خادم Ntfy', + 'admin.notifications.adminNtfyPanel.serverPlaceholder': 'https://ntfy.sh', + 'admin.notifications.adminNtfyPanel.topicLabel': 'موضوع المسؤول', + 'admin.notifications.adminNtfyPanel.topicPlaceholder': 'trek-admin-alerts', + 'admin.notifications.adminNtfyPanel.tokenLabel': 'رمز الوصول (اختياري)', + 'admin.notifications.adminNtfyPanel.saved': 'تم حفظ إعدادات Ntfy للمسؤول', + 'admin.notifications.adminNtfyPanel.test': 'إرسال Ntfy تجريبي', + 'admin.notifications.adminNtfyPanel.testSuccess': 'تم إرسال Ntfy التجريبي بنجاح', + 'admin.notifications.adminNtfyPanel.testFailed': 'فشل إرسال Ntfy التجريبي', + 'admin.notifications.adminNtfyPanel.alwaysOnHint': 'يُرسل Ntfy للمسؤول دائمًا عند تهيئة موضوع', 'admin.notifications.adminNotificationsHint': 'حدد القنوات التي تُسلّم إشعارات المسؤول (مثل تنبيهات الإصدارات). يُرسل الـ Webhook تلقائيًا عند تعيين رابط URL لـ Webhook المسؤول.', 'admin.tabs.notifications': 'الإشعارات', 'notifications.versionAvailable.title': 'تحديث متاح', diff --git a/client/src/i18n/translations/br.ts b/client/src/i18n/translations/br.ts index 346bafc9..ef41ec53 100644 --- a/client/src/i18n/translations/br.ts +++ b/client/src/i18n/translations/br.ts @@ -1758,14 +1758,27 @@ const br: Record = { 'settings.webhookUrl.label': 'URL do webhook', 'settings.webhookUrl.placeholder': 'https://discord.com/api/webhooks/...', 'settings.webhookUrl.hint': 'Insira a URL do seu webhook do Discord, Slack ou personalizado para receber notificações.', - 'settings.webhookUrl.save': 'Salvar', 'settings.webhookUrl.saved': 'URL do webhook salva', 'settings.webhookUrl.test': 'Testar', 'settings.webhookUrl.testSuccess': 'Webhook de teste enviado com sucesso', 'settings.webhookUrl.testFailed': 'Falha no webhook de teste', + 'settings.ntfyUrl.topicLabel': 'Tópico Ntfy', + 'settings.ntfyUrl.topicPlaceholder': 'my-trek-alerts', + 'settings.ntfyUrl.serverLabel': 'URL do servidor Ntfy (opcional)', + 'settings.ntfyUrl.serverPlaceholder': 'https://ntfy.sh', + 'settings.ntfyUrl.hint': 'Insira seu tópico Ntfy para receber notificações push. Deixe o servidor em branco para usar o padrão configurado pelo seu administrador.', + 'settings.ntfyUrl.tokenLabel': 'Token de acesso (opcional)', + 'settings.ntfyUrl.tokenHint': 'Necessário para tópicos protegidos por senha.', + 'settings.ntfyUrl.saved': 'Configurações do Ntfy salvas', + 'settings.ntfyUrl.test': 'Testar', + 'settings.ntfyUrl.testSuccess': 'Notificação de teste do Ntfy enviada com sucesso', + 'settings.ntfyUrl.testFailed': 'Falha na notificação de teste do Ntfy', + 'settings.ntfyUrl.clearToken': 'Limpar', + 'settings.ntfyUrl.tokenCleared': 'Token de acesso removido', 'settings.notificationPreferences.inapp': 'In-App', 'settings.notificationPreferences.webhook': 'Webhook', 'settings.notificationPreferences.email': 'Email', + 'settings.notificationPreferences.ntfy': 'Ntfy', 'admin.notifications.emailPanel.title': 'Email (SMTP)', 'admin.notifications.webhookPanel.title': 'Webhook', 'admin.notifications.inappPanel.title': 'In-App', @@ -1776,6 +1789,22 @@ const br: Record = { 'admin.notifications.adminWebhookPanel.testSuccess': 'Webhook de teste enviado com sucesso', 'admin.notifications.adminWebhookPanel.testFailed': 'Falha no webhook de teste', 'admin.notifications.adminWebhookPanel.alwaysOnHint': 'O webhook de admin dispara automaticamente quando uma URL está configurada', + 'admin.notifications.ntfy': 'Ntfy', + 'admin.notifications.testNtfy': 'Enviar Ntfy de teste', + 'admin.notifications.testNtfySuccess': 'Ntfy de teste enviado com sucesso', + 'admin.notifications.testNtfyFailed': 'Falha ao enviar Ntfy de teste', + 'admin.notifications.adminNtfyPanel.title': 'Ntfy de admin', + 'admin.notifications.adminNtfyPanel.hint': 'Este tópico Ntfy é usado exclusivamente para notificações de admin (ex. alertas de versão). É independente dos tópicos por usuário e sempre dispara quando configurado.', + 'admin.notifications.adminNtfyPanel.serverLabel': 'URL do servidor Ntfy', + 'admin.notifications.adminNtfyPanel.serverPlaceholder': 'https://ntfy.sh', + 'admin.notifications.adminNtfyPanel.topicLabel': 'Tópico de admin', + 'admin.notifications.adminNtfyPanel.topicPlaceholder': 'trek-admin-alerts', + 'admin.notifications.adminNtfyPanel.tokenLabel': 'Token de acesso (opcional)', + 'admin.notifications.adminNtfyPanel.saved': 'Configurações de Ntfy de admin salvas', + 'admin.notifications.adminNtfyPanel.test': 'Enviar Ntfy de teste', + 'admin.notifications.adminNtfyPanel.testSuccess': 'Ntfy de teste enviado com sucesso', + 'admin.notifications.adminNtfyPanel.testFailed': 'Falha ao enviar Ntfy de teste', + 'admin.notifications.adminNtfyPanel.alwaysOnHint': 'O Ntfy de admin sempre dispara quando um tópico está configurado', 'admin.notifications.adminNotificationsHint': 'Configure quais canais entregam notificações de admin (ex. alertas de versão). O webhook dispara automaticamente se uma URL de webhook de admin estiver definida.', 'admin.tabs.notifications': 'Notificações', 'notifications.versionAvailable.title': 'Atualização disponível', diff --git a/client/src/i18n/translations/cs.ts b/client/src/i18n/translations/cs.ts index 283fb7e8..e4ab10f9 100644 --- a/client/src/i18n/translations/cs.ts +++ b/client/src/i18n/translations/cs.ts @@ -1763,14 +1763,27 @@ const cs: Record = { 'settings.webhookUrl.label': 'URL webhooku', 'settings.webhookUrl.placeholder': 'https://discord.com/api/webhooks/...', 'settings.webhookUrl.hint': 'Zadejte URL vašeho Discord, Slack nebo vlastního webhooku pro příjem oznámení.', - 'settings.webhookUrl.save': 'Uložit', 'settings.webhookUrl.saved': 'URL webhooku uložena', 'settings.webhookUrl.test': 'Otestovat', 'settings.webhookUrl.testSuccess': 'Testovací webhook byl úspěšně odeslán', 'settings.webhookUrl.testFailed': 'Testovací webhook selhal', + 'settings.ntfyUrl.topicLabel': 'Téma Ntfy', + 'settings.ntfyUrl.topicPlaceholder': 'my-trek-alerts', + 'settings.ntfyUrl.serverLabel': 'URL serveru Ntfy (volitelné)', + 'settings.ntfyUrl.serverPlaceholder': 'https://ntfy.sh', + 'settings.ntfyUrl.hint': 'Zadejte své téma Ntfy pro příjem push notifikací. Pole serveru ponechte prázdné pro použití výchozího nastavení správce.', + 'settings.ntfyUrl.tokenLabel': 'Přístupový token (volitelné)', + 'settings.ntfyUrl.tokenHint': 'Vyžadováno pro témata chráněná heslem.', + 'settings.ntfyUrl.saved': 'Nastavení Ntfy uloženo', + 'settings.ntfyUrl.test': 'Otestovat', + 'settings.ntfyUrl.testSuccess': 'Testovací notifikace Ntfy byla úspěšně odeslána', + 'settings.ntfyUrl.testFailed': 'Testovací notifikace Ntfy selhala', + 'settings.ntfyUrl.clearToken': 'Vymazat', + 'settings.ntfyUrl.tokenCleared': 'Přístupový token byl vymazán', 'settings.notificationPreferences.inapp': 'In-App', 'settings.notificationPreferences.webhook': 'Webhook', 'settings.notificationPreferences.email': 'Email', + 'settings.notificationPreferences.ntfy': 'Ntfy', 'admin.notifications.emailPanel.title': 'Email (SMTP)', 'admin.notifications.webhookPanel.title': 'Webhook', 'admin.notifications.inappPanel.title': 'In-App', @@ -1781,6 +1794,22 @@ const cs: Record = { 'admin.notifications.adminWebhookPanel.testSuccess': 'Testovací webhook byl úspěšně odeslán', 'admin.notifications.adminWebhookPanel.testFailed': 'Testovací webhook selhal', 'admin.notifications.adminWebhookPanel.alwaysOnHint': 'Admin webhook odesílá automaticky, pokud je nastavena URL', + 'admin.notifications.ntfy': 'Ntfy', + 'admin.notifications.testNtfy': 'Odeslat testovací Ntfy', + 'admin.notifications.testNtfySuccess': 'Testovací Ntfy bylo úspěšně odesláno', + 'admin.notifications.testNtfyFailed': 'Testovací Ntfy selhalo', + 'admin.notifications.adminNtfyPanel.title': 'Admin Ntfy', + 'admin.notifications.adminNtfyPanel.hint': 'Toto téma Ntfy se používá výhradně pro admin oznámení (např. upozornění na verze). Je nezávislé na tématech uživatelů a odesílá vždy, když je nakonfigurováno.', + 'admin.notifications.adminNtfyPanel.serverLabel': 'URL serveru Ntfy', + 'admin.notifications.adminNtfyPanel.serverPlaceholder': 'https://ntfy.sh', + 'admin.notifications.adminNtfyPanel.topicLabel': 'Admin téma', + 'admin.notifications.adminNtfyPanel.topicPlaceholder': 'trek-admin-alerts', + 'admin.notifications.adminNtfyPanel.tokenLabel': 'Přístupový token (volitelné)', + 'admin.notifications.adminNtfyPanel.saved': 'Nastavení admin Ntfy uloženo', + 'admin.notifications.adminNtfyPanel.test': 'Odeslat testovací Ntfy', + 'admin.notifications.adminNtfyPanel.testSuccess': 'Testovací Ntfy bylo úspěšně odesláno', + 'admin.notifications.adminNtfyPanel.testFailed': 'Testovací Ntfy selhalo', + 'admin.notifications.adminNtfyPanel.alwaysOnHint': 'Admin Ntfy odesílá vždy, když je nakonfigurováno téma', 'admin.notifications.adminNotificationsHint': 'Nastavte, které kanály doručují admin oznámení (např. upozornění na verze). Webhook odesílá automaticky, pokud je nastavena URL admin webhooku.', 'admin.tabs.notifications': 'Oznámení', 'notifications.versionAvailable.title': 'Dostupná aktualizace', diff --git a/client/src/i18n/translations/de.ts b/client/src/i18n/translations/de.ts index 2c9e54ee..78af3e2a 100644 --- a/client/src/i18n/translations/de.ts +++ b/client/src/i18n/translations/de.ts @@ -1766,14 +1766,27 @@ const de: Record = { 'settings.webhookUrl.label': 'Webhook-URL', 'settings.webhookUrl.placeholder': 'https://discord.com/api/webhooks/...', 'settings.webhookUrl.hint': 'Gib deine Discord-, Slack- oder benutzerdefinierte Webhook-URL ein, um Benachrichtigungen zu erhalten.', - 'settings.webhookUrl.save': 'Speichern', 'settings.webhookUrl.saved': 'Webhook-URL gespeichert', 'settings.webhookUrl.test': 'Testen', 'settings.webhookUrl.testSuccess': 'Test-Webhook erfolgreich gesendet', 'settings.webhookUrl.testFailed': 'Test-Webhook fehlgeschlagen', + 'settings.ntfyUrl.topicLabel': 'Ntfy-Thema', + 'settings.ntfyUrl.topicPlaceholder': 'my-trek-alerts', + 'settings.ntfyUrl.serverLabel': 'Ntfy-Server-URL (optional)', + 'settings.ntfyUrl.serverPlaceholder': 'https://ntfy.sh', + 'settings.ntfyUrl.hint': 'Gib dein Ntfy-Thema ein, um Push-Benachrichtigungen zu erhalten. Lasse das Server-Feld leer, um den vom Administrator konfigurierten Standard zu verwenden.', + 'settings.ntfyUrl.tokenLabel': 'Zugriffstoken (optional)', + 'settings.ntfyUrl.tokenHint': 'Erforderlich für passwortgeschützte Themen.', + 'settings.ntfyUrl.saved': 'Ntfy-Einstellungen gespeichert', + 'settings.ntfyUrl.test': 'Testen', + 'settings.ntfyUrl.testSuccess': 'Test-Ntfy-Benachrichtigung erfolgreich gesendet', + 'settings.ntfyUrl.testFailed': 'Test-Ntfy-Benachrichtigung fehlgeschlagen', + 'settings.ntfyUrl.clearToken': 'Löschen', + 'settings.ntfyUrl.tokenCleared': 'Zugriffstoken gelöscht', 'settings.notificationPreferences.inapp': 'In-App', 'settings.notificationPreferences.webhook': 'Webhook', 'settings.notificationPreferences.email': 'Email', + 'settings.notificationPreferences.ntfy': 'Ntfy', 'admin.notifications.emailPanel.title': 'Email (SMTP)', 'admin.notifications.webhookPanel.title': 'Webhook', 'admin.notifications.inappPanel.title': 'In-App', @@ -1784,6 +1797,22 @@ const de: Record = { 'admin.notifications.adminWebhookPanel.testSuccess': 'Test-Webhook erfolgreich gesendet', 'admin.notifications.adminWebhookPanel.testFailed': 'Test-Webhook fehlgeschlagen', 'admin.notifications.adminWebhookPanel.alwaysOnHint': 'Admin-Webhook sendet automatisch, wenn eine URL konfiguriert ist', + 'admin.notifications.ntfy': 'Ntfy', + 'admin.notifications.testNtfy': 'Test-Ntfy senden', + 'admin.notifications.testNtfySuccess': 'Test-Ntfy erfolgreich gesendet', + 'admin.notifications.testNtfyFailed': 'Test-Ntfy fehlgeschlagen', + 'admin.notifications.adminNtfyPanel.title': 'Admin-Ntfy', + 'admin.notifications.adminNtfyPanel.hint': 'Dieses Ntfy-Thema wird ausschließlich für Admin-Benachrichtigungen verwendet (z. B. Versions-Updates). Es ist unabhängig von Benutzer-Themen und sendet immer, wenn es konfiguriert ist.', + 'admin.notifications.adminNtfyPanel.serverLabel': 'Ntfy-Server-URL', + 'admin.notifications.adminNtfyPanel.serverPlaceholder': 'https://ntfy.sh', + 'admin.notifications.adminNtfyPanel.topicLabel': 'Admin-Thema', + 'admin.notifications.adminNtfyPanel.topicPlaceholder': 'trek-admin-alerts', + 'admin.notifications.adminNtfyPanel.tokenLabel': 'Zugriffstoken (optional)', + 'admin.notifications.adminNtfyPanel.saved': 'Admin-Ntfy-Einstellungen gespeichert', + 'admin.notifications.adminNtfyPanel.test': 'Test-Ntfy senden', + 'admin.notifications.adminNtfyPanel.testSuccess': 'Test-Ntfy erfolgreich gesendet', + 'admin.notifications.adminNtfyPanel.testFailed': 'Test-Ntfy fehlgeschlagen', + 'admin.notifications.adminNtfyPanel.alwaysOnHint': 'Admin-Ntfy sendet immer, wenn ein Thema konfiguriert ist', 'admin.notifications.adminNotificationsHint': 'Konfiguriere, welche Kanäle Admin-Benachrichtigungen liefern (z. B. Versions-Updates). Der Webhook sendet automatisch, wenn eine Admin-Webhook-URL gesetzt ist.', 'admin.tabs.notifications': 'Benachrichtigungen', 'notifications.versionAvailable.title': 'Update verfügbar', diff --git a/client/src/i18n/translations/en.ts b/client/src/i18n/translations/en.ts index 056e3773..d20e6243 100644 --- a/client/src/i18n/translations/en.ts +++ b/client/src/i18n/translations/en.ts @@ -189,25 +189,42 @@ const en: Record = { 'settings.notificationPreferences.email': 'Email', 'settings.notificationPreferences.webhook': 'Webhook', 'settings.notificationPreferences.inapp': 'In-App', + 'settings.notificationPreferences.ntfy': 'Ntfy', 'settings.notificationPreferences.noChannels': 'No notification channels are configured. Ask an admin to set up email or webhook notifications.', 'settings.webhookUrl.label': 'Webhook URL', 'settings.webhookUrl.placeholder': 'https://discord.com/api/webhooks/...', 'settings.webhookUrl.hint': 'Enter your Discord, Slack, or custom webhook URL to receive notifications.', - 'settings.webhookUrl.save': 'Save', 'settings.webhookUrl.saved': 'Webhook URL saved', 'settings.webhookUrl.test': 'Test', 'settings.webhookUrl.testSuccess': 'Test webhook sent successfully', 'settings.webhookUrl.testFailed': 'Test webhook failed', + 'settings.ntfyUrl.topicLabel': 'Ntfy Topic', + 'settings.ntfyUrl.topicPlaceholder': 'my-trek-alerts', + 'settings.ntfyUrl.serverLabel': 'Ntfy Server URL (optional)', + 'settings.ntfyUrl.serverPlaceholder': 'https://ntfy.sh', + 'settings.ntfyUrl.hint': 'Enter your ntfy topic to receive push notifications. Leave server blank to use the default configured by your admin.', + 'settings.ntfyUrl.tokenLabel': 'Access Token (optional)', + 'settings.ntfyUrl.tokenHint': 'Required for password-protected topics.', + 'settings.ntfyUrl.saved': 'Ntfy settings saved', + 'settings.ntfyUrl.test': 'Test', + 'settings.ntfyUrl.testSuccess': 'Test ntfy notification sent successfully', + 'settings.ntfyUrl.testFailed': 'Test ntfy notification failed', + 'settings.ntfyUrl.clearToken': 'Clear', + 'settings.ntfyUrl.tokenCleared': 'Access token cleared', 'admin.notifications.title': 'Notifications', 'admin.notifications.hint': 'Choose one notification channel. Only one can be active at a time.', 'admin.notifications.none': 'Disabled', 'admin.notifications.email': 'Email (SMTP)', 'admin.notifications.webhook': 'Webhook', + 'admin.notifications.ntfy': 'Ntfy', 'admin.notifications.save': 'Save notification settings', 'admin.notifications.saved': 'Notification settings saved', 'admin.notifications.testWebhook': 'Send test webhook', 'admin.notifications.testWebhookSuccess': 'Test webhook sent successfully', 'admin.notifications.testWebhookFailed': 'Test webhook failed', + 'admin.notifications.testNtfy': 'Send test ntfy', + 'admin.notifications.testNtfySuccess': 'Test ntfy sent successfully', + 'admin.notifications.testNtfyFailed': 'Test ntfy failed', 'admin.notifications.emailPanel.title': 'Email (SMTP)', 'admin.notifications.webhookPanel.title': 'Webhook', 'admin.notifications.inappPanel.title': 'In-App', @@ -218,6 +235,18 @@ const en: Record = { 'admin.notifications.adminWebhookPanel.testSuccess': 'Test webhook sent successfully', 'admin.notifications.adminWebhookPanel.testFailed': 'Test webhook failed', 'admin.notifications.adminWebhookPanel.alwaysOnHint': 'Admin webhook always fires when a URL is configured', + 'admin.notifications.adminNtfyPanel.title': 'Admin Ntfy', + 'admin.notifications.adminNtfyPanel.hint': 'This ntfy topic is used exclusively for admin notifications (e.g. version alerts). It is separate from per-user topics and always fires when configured.', + 'admin.notifications.adminNtfyPanel.serverLabel': 'Ntfy Server URL', + 'admin.notifications.adminNtfyPanel.serverPlaceholder': 'https://ntfy.sh', + 'admin.notifications.adminNtfyPanel.topicLabel': 'Admin Topic', + 'admin.notifications.adminNtfyPanel.topicPlaceholder': 'trek-admin-alerts', + 'admin.notifications.adminNtfyPanel.tokenLabel': 'Access Token (optional)', + 'admin.notifications.adminNtfyPanel.saved': 'Admin ntfy settings saved', + 'admin.notifications.adminNtfyPanel.test': 'Send test ntfy', + 'admin.notifications.adminNtfyPanel.testSuccess': 'Test ntfy sent successfully', + 'admin.notifications.adminNtfyPanel.testFailed': 'Test ntfy failed', + 'admin.notifications.adminNtfyPanel.alwaysOnHint': 'Admin ntfy always fires when a topic is configured', 'admin.notifications.adminNotificationsHint': 'Configure which channels deliver admin-only notifications (e.g. version alerts).', 'admin.smtp.title': 'Email & Notifications', 'admin.smtp.hint': 'SMTP configuration for sending email notifications.', diff --git a/client/src/i18n/translations/es.ts b/client/src/i18n/translations/es.ts index 1a64e5e2..0b13fd92 100644 --- a/client/src/i18n/translations/es.ts +++ b/client/src/i18n/translations/es.ts @@ -1768,14 +1768,27 @@ const es: Record = { 'settings.webhookUrl.label': 'URL del webhook', 'settings.webhookUrl.placeholder': 'https://discord.com/api/webhooks/...', 'settings.webhookUrl.hint': 'Introduce tu URL de webhook de Discord, Slack o personalizada para recibir notificaciones.', - 'settings.webhookUrl.save': 'Guardar', 'settings.webhookUrl.saved': 'URL del webhook guardada', 'settings.webhookUrl.test': 'Probar', 'settings.webhookUrl.testSuccess': 'Webhook de prueba enviado correctamente', 'settings.webhookUrl.testFailed': 'Error al enviar el webhook de prueba', + 'settings.ntfyUrl.topicLabel': 'Tema de Ntfy', + 'settings.ntfyUrl.topicPlaceholder': 'my-trek-alerts', + 'settings.ntfyUrl.serverLabel': 'URL del servidor Ntfy (opcional)', + 'settings.ntfyUrl.serverPlaceholder': 'https://ntfy.sh', + 'settings.ntfyUrl.hint': 'Introduce tu tema de Ntfy para recibir notificaciones push. Deja el servidor en blanco para usar el predeterminado configurado por tu administrador.', + 'settings.ntfyUrl.tokenLabel': 'Token de acceso (opcional)', + 'settings.ntfyUrl.tokenHint': 'Requerido para temas protegidos con contraseña.', + 'settings.ntfyUrl.saved': 'Configuración de Ntfy guardada', + 'settings.ntfyUrl.test': 'Probar', + 'settings.ntfyUrl.testSuccess': 'Notificación de prueba de Ntfy enviada correctamente', + 'settings.ntfyUrl.testFailed': 'Error en la notificación de prueba de Ntfy', + 'settings.ntfyUrl.clearToken': 'Borrar', + 'settings.ntfyUrl.tokenCleared': 'Token de acceso eliminado', 'settings.notificationPreferences.inapp': 'In-App', 'settings.notificationPreferences.webhook': 'Webhook', 'settings.notificationPreferences.email': 'Email', + 'settings.notificationPreferences.ntfy': 'Ntfy', 'admin.notifications.emailPanel.title': 'Email (SMTP)', 'admin.notifications.webhookPanel.title': 'Webhook', 'admin.notifications.inappPanel.title': 'In-App', @@ -1786,6 +1799,22 @@ const es: Record = { 'admin.notifications.adminWebhookPanel.testSuccess': 'Webhook de prueba enviado correctamente', 'admin.notifications.adminWebhookPanel.testFailed': 'Error al enviar el webhook de prueba', 'admin.notifications.adminWebhookPanel.alwaysOnHint': 'El webhook de admin se activa automáticamente si hay una URL configurada', + 'admin.notifications.ntfy': 'Ntfy', + 'admin.notifications.testNtfy': 'Enviar Ntfy de prueba', + 'admin.notifications.testNtfySuccess': 'Ntfy de prueba enviado correctamente', + 'admin.notifications.testNtfyFailed': 'Error al enviar el Ntfy de prueba', + 'admin.notifications.adminNtfyPanel.title': 'Ntfy de admin', + 'admin.notifications.adminNtfyPanel.hint': 'Este tema Ntfy se usa exclusivamente para notificaciones de admin (ej. alertas de versión). Es independiente de los temas por usuario y siempre se activa cuando está configurado.', + 'admin.notifications.adminNtfyPanel.serverLabel': 'URL del servidor Ntfy', + 'admin.notifications.adminNtfyPanel.serverPlaceholder': 'https://ntfy.sh', + 'admin.notifications.adminNtfyPanel.topicLabel': 'Tema de admin', + 'admin.notifications.adminNtfyPanel.topicPlaceholder': 'trek-admin-alerts', + 'admin.notifications.adminNtfyPanel.tokenLabel': 'Token de acceso (opcional)', + 'admin.notifications.adminNtfyPanel.saved': 'Configuración de Ntfy de admin guardada', + 'admin.notifications.adminNtfyPanel.test': 'Enviar Ntfy de prueba', + 'admin.notifications.adminNtfyPanel.testSuccess': 'Ntfy de prueba enviado correctamente', + 'admin.notifications.adminNtfyPanel.testFailed': 'Error al enviar el Ntfy de prueba', + 'admin.notifications.adminNtfyPanel.alwaysOnHint': 'El Ntfy de admin siempre se activa cuando hay un tema configurado', 'admin.notifications.adminNotificationsHint': 'Configura qué canales entregan notificaciones de admin (ej. alertas de versión). El webhook se activa automáticamente si hay una URL de webhook de admin configurada.', 'admin.tabs.notifications': 'Notificaciones', 'notifications.versionAvailable.title': 'Actualización disponible', diff --git a/client/src/i18n/translations/fr.ts b/client/src/i18n/translations/fr.ts index 11ce8012..afe7146e 100644 --- a/client/src/i18n/translations/fr.ts +++ b/client/src/i18n/translations/fr.ts @@ -1762,14 +1762,27 @@ const fr: Record = { 'settings.webhookUrl.label': 'URL du webhook', 'settings.webhookUrl.placeholder': 'https://discord.com/api/webhooks/...', 'settings.webhookUrl.hint': 'Entrez votre URL de webhook Discord, Slack ou personnalisée pour recevoir des notifications.', - 'settings.webhookUrl.save': 'Enregistrer', 'settings.webhookUrl.saved': 'URL du webhook enregistrée', 'settings.webhookUrl.test': 'Tester', 'settings.webhookUrl.testSuccess': 'Webhook de test envoyé avec succès', 'settings.webhookUrl.testFailed': 'Échec du webhook de test', + 'settings.ntfyUrl.topicLabel': 'Sujet Ntfy', + 'settings.ntfyUrl.topicPlaceholder': 'my-trek-alerts', + 'settings.ntfyUrl.serverLabel': "URL du serveur Ntfy (optionnel)", + 'settings.ntfyUrl.serverPlaceholder': 'https://ntfy.sh', + 'settings.ntfyUrl.hint': "Entrez votre sujet Ntfy pour recevoir des notifications push. Laissez le serveur vide pour utiliser la valeur par défaut configurée par votre administrateur.", + 'settings.ntfyUrl.tokenLabel': "Jeton d'accès (optionnel)", + 'settings.ntfyUrl.tokenHint': 'Requis pour les sujets protégés par mot de passe.', + 'settings.ntfyUrl.saved': 'Paramètres Ntfy enregistrés', + 'settings.ntfyUrl.test': 'Tester', + 'settings.ntfyUrl.testSuccess': 'Notification de test Ntfy envoyée avec succès', + 'settings.ntfyUrl.testFailed': 'Échec de la notification de test Ntfy', + 'settings.ntfyUrl.clearToken': 'Effacer', + 'settings.ntfyUrl.tokenCleared': "Jeton d'accès effacé", 'settings.notificationPreferences.inapp': 'In-App', 'settings.notificationPreferences.webhook': 'Webhook', 'settings.notificationPreferences.email': 'Email', + 'settings.notificationPreferences.ntfy': 'Ntfy', 'admin.notifications.emailPanel.title': 'Email (SMTP)', 'admin.notifications.webhookPanel.title': 'Webhook', 'admin.notifications.inappPanel.title': 'In-App', @@ -1780,6 +1793,22 @@ const fr: Record = { 'admin.notifications.adminWebhookPanel.testSuccess': 'Webhook de test envoyé avec succès', 'admin.notifications.adminWebhookPanel.testFailed': 'Échec du webhook de test', 'admin.notifications.adminWebhookPanel.alwaysOnHint': 'Le webhook admin s\'active automatiquement si une URL est configurée', + 'admin.notifications.ntfy': 'Ntfy', + 'admin.notifications.testNtfy': 'Envoyer un Ntfy de test', + 'admin.notifications.testNtfySuccess': 'Ntfy de test envoyé avec succès', + 'admin.notifications.testNtfyFailed': 'Échec de l\'envoi du Ntfy de test', + 'admin.notifications.adminNtfyPanel.title': 'Ntfy admin', + 'admin.notifications.adminNtfyPanel.hint': 'Ce sujet Ntfy est utilisé exclusivement pour les notifications admin (ex. alertes de version). Il est séparé des sujets par utilisateur et s\'active toujours lorsqu\'il est configuré.', + 'admin.notifications.adminNtfyPanel.serverLabel': 'URL du serveur Ntfy', + 'admin.notifications.adminNtfyPanel.serverPlaceholder': 'https://ntfy.sh', + 'admin.notifications.adminNtfyPanel.topicLabel': 'Sujet admin', + 'admin.notifications.adminNtfyPanel.topicPlaceholder': 'trek-admin-alerts', + 'admin.notifications.adminNtfyPanel.tokenLabel': "Jeton d'accès (optionnel)", + 'admin.notifications.adminNtfyPanel.saved': 'Paramètres Ntfy admin enregistrés', + 'admin.notifications.adminNtfyPanel.test': 'Envoyer un Ntfy de test', + 'admin.notifications.adminNtfyPanel.testSuccess': 'Ntfy de test envoyé avec succès', + 'admin.notifications.adminNtfyPanel.testFailed': 'Échec de l\'envoi du Ntfy de test', + 'admin.notifications.adminNtfyPanel.alwaysOnHint': 'Le Ntfy admin s\'active toujours lorsqu\'un sujet est configuré', 'admin.notifications.adminNotificationsHint': 'Configurez quels canaux envoient les notifications admin (ex. alertes de version). Le webhook s\'active automatiquement si une URL webhook admin est définie.', 'admin.tabs.notifications': 'Notifications', 'notifications.versionAvailable.title': 'Mise à jour disponible', diff --git a/client/src/i18n/translations/hu.ts b/client/src/i18n/translations/hu.ts index 2d64ae63..93dce675 100644 --- a/client/src/i18n/translations/hu.ts +++ b/client/src/i18n/translations/hu.ts @@ -1760,14 +1760,27 @@ const hu: Record = { 'settings.webhookUrl.label': 'Webhook URL', 'settings.webhookUrl.placeholder': 'https://discord.com/api/webhooks/...', 'settings.webhookUrl.hint': 'Adja meg a Discord, Slack vagy egyéni webhook URL-jét az értesítések fogadásához.', - 'settings.webhookUrl.save': 'Mentés', 'settings.webhookUrl.saved': 'Webhook URL mentve', 'settings.webhookUrl.test': 'Teszt', 'settings.webhookUrl.testSuccess': 'Teszt webhook sikeresen elküldve', 'settings.webhookUrl.testFailed': 'Teszt webhook sikertelen', + 'settings.ntfyUrl.topicLabel': 'Ntfy téma', + 'settings.ntfyUrl.topicPlaceholder': 'my-trek-alerts', + 'settings.ntfyUrl.serverLabel': 'Ntfy szerver URL (opcionális)', + 'settings.ntfyUrl.serverPlaceholder': 'https://ntfy.sh', + 'settings.ntfyUrl.hint': 'Add meg az Ntfy témádat push értesítések fogadásához. Hagyd üresen a szervert a rendszergazda által beállított alapértelmezett használatához.', + 'settings.ntfyUrl.tokenLabel': 'Hozzáférési token (opcionális)', + 'settings.ntfyUrl.tokenHint': 'Jelszóval védett témákhoz szükséges.', + 'settings.ntfyUrl.saved': 'Ntfy beállítások mentve', + 'settings.ntfyUrl.test': 'Teszt', + 'settings.ntfyUrl.testSuccess': 'Teszt Ntfy értesítés sikeresen elküldve', + 'settings.ntfyUrl.testFailed': 'Teszt Ntfy értesítés sikertelen', + 'settings.ntfyUrl.clearToken': 'Törlés', + 'settings.ntfyUrl.tokenCleared': 'Hozzáférési token törölve', 'settings.notificationPreferences.inapp': 'In-App', 'settings.notificationPreferences.webhook': 'Webhook', 'settings.notificationPreferences.email': 'Email', + 'settings.notificationPreferences.ntfy': 'Ntfy', 'admin.notifications.emailPanel.title': 'Email (SMTP)', 'admin.notifications.webhookPanel.title': 'Webhook', 'admin.notifications.inappPanel.title': 'In-App', @@ -1778,6 +1791,22 @@ const hu: Record = { 'admin.notifications.adminWebhookPanel.testSuccess': 'Teszt webhook sikeresen elküldve', 'admin.notifications.adminWebhookPanel.testFailed': 'Teszt webhook sikertelen', 'admin.notifications.adminWebhookPanel.alwaysOnHint': 'Az admin webhook automatikusan küld, ha URL van beállítva', + 'admin.notifications.ntfy': 'Ntfy', + 'admin.notifications.testNtfy': 'Teszt Ntfy küldése', + 'admin.notifications.testNtfySuccess': 'Teszt Ntfy sikeresen elküldve', + 'admin.notifications.testNtfyFailed': 'Teszt Ntfy sikertelen', + 'admin.notifications.adminNtfyPanel.title': 'Admin Ntfy', + 'admin.notifications.adminNtfyPanel.hint': 'Ez az Ntfy téma kizárólag admin értesítésekhez használatos (pl. verziófrissítési figyelmeztetések). Független a felhasználói témáktól, és mindig küld, ha konfigurálva van.', + 'admin.notifications.adminNtfyPanel.serverLabel': 'Ntfy szerver URL', + 'admin.notifications.adminNtfyPanel.serverPlaceholder': 'https://ntfy.sh', + 'admin.notifications.adminNtfyPanel.topicLabel': 'Admin téma', + 'admin.notifications.adminNtfyPanel.topicPlaceholder': 'trek-admin-alerts', + 'admin.notifications.adminNtfyPanel.tokenLabel': 'Hozzáférési token (opcionális)', + 'admin.notifications.adminNtfyPanel.saved': 'Admin Ntfy beállítások mentve', + 'admin.notifications.adminNtfyPanel.test': 'Teszt Ntfy küldése', + 'admin.notifications.adminNtfyPanel.testSuccess': 'Teszt Ntfy sikeresen elküldve', + 'admin.notifications.adminNtfyPanel.testFailed': 'Teszt Ntfy sikertelen', + 'admin.notifications.adminNtfyPanel.alwaysOnHint': 'Az admin Ntfy mindig küld, ha egy téma konfigurálva van', 'admin.notifications.adminNotificationsHint': 'Állítsa be, hogy mely csatornák szállítsák az admin értesítéseket (pl. verziófrissítési figyelmeztetések). A webhook automatikusan küld, ha admin webhook URL van megadva.', 'admin.tabs.notifications': 'Értesítések', 'notifications.versionAvailable.title': 'Elérhető frissítés', diff --git a/client/src/i18n/translations/id.ts b/client/src/i18n/translations/id.ts index e6bb27f5..03eff31d 100644 --- a/client/src/i18n/translations/id.ts +++ b/client/src/i18n/translations/id.ts @@ -189,15 +189,28 @@ const id: Record = { 'settings.notificationPreferences.email': 'Email', 'settings.notificationPreferences.webhook': 'Webhook', 'settings.notificationPreferences.inapp': 'In-App', + 'settings.notificationPreferences.ntfy': 'Ntfy', 'settings.notificationPreferences.noChannels': 'Belum ada saluran notifikasi yang dikonfigurasi. Minta admin untuk mengatur notifikasi email atau webhook.', 'settings.webhookUrl.label': 'Webhook URL', 'settings.webhookUrl.placeholder': 'https://discord.com/api/webhooks/...', 'settings.webhookUrl.hint': 'Masukkan URL webhook Discord, Slack, atau kustom untuk menerima notifikasi.', - 'settings.webhookUrl.save': 'Simpan', 'settings.webhookUrl.saved': 'Webhook URL tersimpan', 'settings.webhookUrl.test': 'Uji', 'settings.webhookUrl.testSuccess': 'Test webhook berhasil dikirim', 'settings.webhookUrl.testFailed': 'Test webhook gagal', + 'settings.ntfyUrl.topicLabel': 'Topik Ntfy', + 'settings.ntfyUrl.topicPlaceholder': 'my-trek-alerts', + 'settings.ntfyUrl.serverLabel': 'URL Server Ntfy (opsional)', + 'settings.ntfyUrl.serverPlaceholder': 'https://ntfy.sh', + 'settings.ntfyUrl.hint': 'Masukkan topik Ntfy Anda untuk menerima notifikasi push. Kosongkan bidang server untuk menggunakan default yang dikonfigurasi oleh admin Anda.', + 'settings.ntfyUrl.tokenLabel': 'Token Akses (opsional)', + 'settings.ntfyUrl.tokenHint': 'Diperlukan untuk topik yang dilindungi kata sandi.', + 'settings.ntfyUrl.saved': 'Pengaturan Ntfy tersimpan', + 'settings.ntfyUrl.test': 'Uji', + 'settings.ntfyUrl.testSuccess': 'Notifikasi uji Ntfy berhasil dikirim', + 'settings.ntfyUrl.testFailed': 'Notifikasi uji Ntfy gagal', + 'settings.ntfyUrl.clearToken': 'Hapus', + 'settings.ntfyUrl.tokenCleared': 'Token akses dihapus', 'admin.notifications.title': 'Notifikasi', 'admin.notifications.hint': 'Pilih satu saluran notifikasi. Hanya satu yang bisa aktif sekaligus.', 'admin.notifications.none': 'Dinonaktifkan', @@ -218,6 +231,22 @@ const id: Record = { 'admin.notifications.adminWebhookPanel.testSuccess': 'Test webhook berhasil dikirim', 'admin.notifications.adminWebhookPanel.testFailed': 'Test webhook gagal', 'admin.notifications.adminWebhookPanel.alwaysOnHint': 'Admin webhook selalu berjalan jika URL dikonfigurasi', + 'admin.notifications.ntfy': 'Ntfy', + 'admin.notifications.testNtfy': 'Kirim uji Ntfy', + 'admin.notifications.testNtfySuccess': 'Uji Ntfy berhasil dikirim', + 'admin.notifications.testNtfyFailed': 'Uji Ntfy gagal', + 'admin.notifications.adminNtfyPanel.title': 'Admin Ntfy', + 'admin.notifications.adminNtfyPanel.hint': 'Topik Ntfy ini digunakan khusus untuk notifikasi admin (mis. peringatan versi). Terpisah dari topik per pengguna dan selalu berjalan jika dikonfigurasi.', + 'admin.notifications.adminNtfyPanel.serverLabel': 'URL Server Ntfy', + 'admin.notifications.adminNtfyPanel.serverPlaceholder': 'https://ntfy.sh', + 'admin.notifications.adminNtfyPanel.topicLabel': 'Topik Admin', + 'admin.notifications.adminNtfyPanel.topicPlaceholder': 'trek-admin-alerts', + 'admin.notifications.adminNtfyPanel.tokenLabel': 'Token Akses (opsional)', + 'admin.notifications.adminNtfyPanel.saved': 'Pengaturan Ntfy admin tersimpan', + 'admin.notifications.adminNtfyPanel.test': 'Kirim uji Ntfy', + 'admin.notifications.adminNtfyPanel.testSuccess': 'Uji Ntfy berhasil dikirim', + 'admin.notifications.adminNtfyPanel.testFailed': 'Uji Ntfy gagal', + 'admin.notifications.adminNtfyPanel.alwaysOnHint': 'Admin Ntfy selalu berjalan jika topik dikonfigurasi', 'admin.notifications.adminNotificationsHint': 'Atur saluran mana yang mengirimkan notifikasi khusus admin (mis. peringatan versi).', 'admin.smtp.title': 'Email & Notifikasi', 'admin.smtp.hint': 'Konfigurasi SMTP untuk pengiriman notifikasi email.', diff --git a/client/src/i18n/translations/it.ts b/client/src/i18n/translations/it.ts index 4c2716c4..7da4fea7 100644 --- a/client/src/i18n/translations/it.ts +++ b/client/src/i18n/translations/it.ts @@ -1763,14 +1763,27 @@ const it: Record = { 'settings.webhookUrl.label': 'URL webhook', 'settings.webhookUrl.placeholder': 'https://discord.com/api/webhooks/...', 'settings.webhookUrl.hint': 'Inserisci il tuo URL webhook Discord, Slack o personalizzato per ricevere notifiche.', - 'settings.webhookUrl.save': 'Salva', 'settings.webhookUrl.saved': 'URL webhook salvato', 'settings.webhookUrl.test': 'Test', 'settings.webhookUrl.testSuccess': 'Webhook di test inviato con successo', 'settings.webhookUrl.testFailed': 'Invio webhook di test fallito', + 'settings.ntfyUrl.topicLabel': 'Argomento Ntfy', + 'settings.ntfyUrl.topicPlaceholder': 'my-trek-alerts', + 'settings.ntfyUrl.serverLabel': 'URL server Ntfy (opzionale)', + 'settings.ntfyUrl.serverPlaceholder': 'https://ntfy.sh', + 'settings.ntfyUrl.hint': "Inserisci il tuo argomento Ntfy per ricevere notifiche push. Lascia il server vuoto per usare il valore predefinito configurato dall'amministratore.", + 'settings.ntfyUrl.tokenLabel': 'Token di accesso (opzionale)', + 'settings.ntfyUrl.tokenHint': 'Richiesto per gli argomenti protetti da password.', + 'settings.ntfyUrl.saved': 'Impostazioni Ntfy salvate', + 'settings.ntfyUrl.test': 'Testa', + 'settings.ntfyUrl.testSuccess': 'Notifica di test Ntfy inviata con successo', + 'settings.ntfyUrl.testFailed': 'Notifica di test Ntfy fallita', + 'settings.ntfyUrl.clearToken': 'Cancella', + 'settings.ntfyUrl.tokenCleared': 'Token di accesso rimosso', 'settings.notificationPreferences.inapp': 'In-App', 'settings.notificationPreferences.webhook': 'Webhook', 'settings.notificationPreferences.email': 'Email', + 'settings.notificationPreferences.ntfy': 'Ntfy', 'admin.notifications.emailPanel.title': 'Email (SMTP)', 'admin.notifications.webhookPanel.title': 'Webhook', 'admin.notifications.inappPanel.title': 'In-App', @@ -1781,6 +1794,22 @@ const it: Record = { 'admin.notifications.adminWebhookPanel.testSuccess': 'Webhook di test inviato con successo', 'admin.notifications.adminWebhookPanel.testFailed': 'Invio webhook di test fallito', 'admin.notifications.adminWebhookPanel.alwaysOnHint': 'Il webhook admin si attiva automaticamente quando è configurato un URL', + 'admin.notifications.ntfy': 'Ntfy', + 'admin.notifications.testNtfy': 'Invia Ntfy di test', + 'admin.notifications.testNtfySuccess': 'Ntfy di test inviato con successo', + 'admin.notifications.testNtfyFailed': 'Invio Ntfy di test fallito', + 'admin.notifications.adminNtfyPanel.title': 'Ntfy admin', + 'admin.notifications.adminNtfyPanel.hint': 'Questo argomento Ntfy viene usato esclusivamente per le notifiche admin (es. avvisi di versione). È separato dagli argomenti per utente e si attiva sempre quando è configurato.', + 'admin.notifications.adminNtfyPanel.serverLabel': 'URL server Ntfy', + 'admin.notifications.adminNtfyPanel.serverPlaceholder': 'https://ntfy.sh', + 'admin.notifications.adminNtfyPanel.topicLabel': 'Argomento admin', + 'admin.notifications.adminNtfyPanel.topicPlaceholder': 'trek-admin-alerts', + 'admin.notifications.adminNtfyPanel.tokenLabel': 'Token di accesso (opzionale)', + 'admin.notifications.adminNtfyPanel.saved': 'Impostazioni Ntfy admin salvate', + 'admin.notifications.adminNtfyPanel.test': 'Invia Ntfy di test', + 'admin.notifications.adminNtfyPanel.testSuccess': 'Ntfy di test inviato con successo', + 'admin.notifications.adminNtfyPanel.testFailed': 'Invio Ntfy di test fallito', + 'admin.notifications.adminNtfyPanel.alwaysOnHint': 'Il Ntfy admin si attiva sempre quando un argomento è configurato', 'admin.notifications.adminNotificationsHint': 'Configura quali canali consegnano le notifiche admin (es. avvisi di versione). Il webhook si attiva automaticamente se è impostato un URL webhook admin.', 'admin.tabs.notifications': 'Notifiche', 'notifications.versionAvailable.title': 'Aggiornamento disponibile', diff --git a/client/src/i18n/translations/nl.ts b/client/src/i18n/translations/nl.ts index 72c9a6aa..5a9e83ba 100644 --- a/client/src/i18n/translations/nl.ts +++ b/client/src/i18n/translations/nl.ts @@ -1762,14 +1762,27 @@ const nl: Record = { 'settings.webhookUrl.label': 'Webhook-URL', 'settings.webhookUrl.placeholder': 'https://discord.com/api/webhooks/...', 'settings.webhookUrl.hint': 'Voer je Discord-, Slack- of aangepaste webhook-URL in om meldingen te ontvangen.', - 'settings.webhookUrl.save': 'Opslaan', 'settings.webhookUrl.saved': 'Webhook-URL opgeslagen', 'settings.webhookUrl.test': 'Testen', 'settings.webhookUrl.testSuccess': 'Test-webhook succesvol verzonden', 'settings.webhookUrl.testFailed': 'Test-webhook mislukt', + 'settings.ntfyUrl.topicLabel': 'Ntfy-onderwerp', + 'settings.ntfyUrl.topicPlaceholder': 'my-trek-alerts', + 'settings.ntfyUrl.serverLabel': 'Ntfy-server-URL (optioneel)', + 'settings.ntfyUrl.serverPlaceholder': 'https://ntfy.sh', + 'settings.ntfyUrl.hint': 'Voer je Ntfy-onderwerp in om pushmeldingen te ontvangen. Laat het serverveld leeg om de standaard te gebruiken die door je beheerder is ingesteld.', + 'settings.ntfyUrl.tokenLabel': 'Toegangstoken (optioneel)', + 'settings.ntfyUrl.tokenHint': 'Vereist voor onderwerpen die met een wachtwoord zijn beveiligd.', + 'settings.ntfyUrl.saved': 'Ntfy-instellingen opgeslagen', + 'settings.ntfyUrl.test': 'Testen', + 'settings.ntfyUrl.testSuccess': 'Test-Ntfy-melding succesvol verzonden', + 'settings.ntfyUrl.testFailed': 'Test-Ntfy-melding mislukt', + 'settings.ntfyUrl.clearToken': 'Wissen', + 'settings.ntfyUrl.tokenCleared': 'Toegangstoken gewist', 'settings.notificationPreferences.inapp': 'In-App', 'settings.notificationPreferences.webhook': 'Webhook', 'settings.notificationPreferences.email': 'Email', + 'settings.notificationPreferences.ntfy': 'Ntfy', 'admin.notifications.emailPanel.title': 'Email (SMTP)', 'admin.notifications.webhookPanel.title': 'Webhook', 'admin.notifications.inappPanel.title': 'In-App', @@ -1780,6 +1793,22 @@ const nl: Record = { 'admin.notifications.adminWebhookPanel.testSuccess': 'Test-webhook succesvol verzonden', 'admin.notifications.adminWebhookPanel.testFailed': 'Test-webhook mislukt', 'admin.notifications.adminWebhookPanel.alwaysOnHint': 'Admin-webhook verstuurt automatisch als er een URL is ingesteld', + 'admin.notifications.ntfy': 'Ntfy', + 'admin.notifications.testNtfy': 'Test-Ntfy verzenden', + 'admin.notifications.testNtfySuccess': 'Test-Ntfy succesvol verzonden', + 'admin.notifications.testNtfyFailed': 'Test-Ntfy mislukt', + 'admin.notifications.adminNtfyPanel.title': 'Admin-Ntfy', + 'admin.notifications.adminNtfyPanel.hint': 'Dit Ntfy-onderwerp wordt uitsluitend gebruikt voor admin-meldingen (bijv. versie-updates). Het staat los van onderwerpen per gebruiker en verstuurt altijd wanneer het geconfigureerd is.', + 'admin.notifications.adminNtfyPanel.serverLabel': 'Ntfy-server-URL', + 'admin.notifications.adminNtfyPanel.serverPlaceholder': 'https://ntfy.sh', + 'admin.notifications.adminNtfyPanel.topicLabel': 'Admin-onderwerp', + 'admin.notifications.adminNtfyPanel.topicPlaceholder': 'trek-admin-alerts', + 'admin.notifications.adminNtfyPanel.tokenLabel': 'Toegangstoken (optioneel)', + 'admin.notifications.adminNtfyPanel.saved': 'Admin-Ntfy-instellingen opgeslagen', + 'admin.notifications.adminNtfyPanel.test': 'Test-Ntfy verzenden', + 'admin.notifications.adminNtfyPanel.testSuccess': 'Test-Ntfy succesvol verzonden', + 'admin.notifications.adminNtfyPanel.testFailed': 'Test-Ntfy mislukt', + 'admin.notifications.adminNtfyPanel.alwaysOnHint': 'Admin-Ntfy verstuurt altijd wanneer een onderwerp is geconfigureerd', 'admin.notifications.adminNotificationsHint': 'Stel in via welke kanalen admin-meldingen worden bezorgd (bijv. versie-updates). De webhook verstuurt automatisch als er een admin-webhook-URL is ingesteld.', 'admin.tabs.notifications': 'Meldingen', 'notifications.versionAvailable.title': 'Update beschikbaar', diff --git a/client/src/i18n/translations/pl.ts b/client/src/i18n/translations/pl.ts index 41d1d899..7f264f55 100644 --- a/client/src/i18n/translations/pl.ts +++ b/client/src/i18n/translations/pl.ts @@ -1596,6 +1596,22 @@ const pl: Record = { 'admin.notifications.adminWebhookPanel.testSuccess': 'Testowy webhook wysłany pomyślnie', 'admin.notifications.adminWebhookPanel.testFailed': 'Wysyłanie testowego webhooka nie powiodło się', 'admin.notifications.adminWebhookPanel.alwaysOnHint': 'Webhook admina wysyła automatycznie, gdy URL jest skonfigurowany', + 'admin.notifications.ntfy': 'Ntfy', + 'admin.notifications.testNtfy': 'Wyślij testowe Ntfy', + 'admin.notifications.testNtfySuccess': 'Testowe Ntfy wysłane pomyślnie', + 'admin.notifications.testNtfyFailed': 'Wysyłanie testowego Ntfy nie powiodło się', + 'admin.notifications.adminNtfyPanel.title': 'Admin Ntfy', + 'admin.notifications.adminNtfyPanel.hint': 'Ten temat Ntfy jest używany wyłącznie do powiadomień admina (np. alertów o wersjach). Jest niezależny od tematów użytkowników i zawsze wysyła po skonfigurowaniu.', + 'admin.notifications.adminNtfyPanel.serverLabel': 'URL serwera Ntfy', + 'admin.notifications.adminNtfyPanel.serverPlaceholder': 'https://ntfy.sh', + 'admin.notifications.adminNtfyPanel.topicLabel': 'Temat admina', + 'admin.notifications.adminNtfyPanel.topicPlaceholder': 'trek-admin-alerts', + 'admin.notifications.adminNtfyPanel.tokenLabel': 'Token dostępu (opcjonalne)', + 'admin.notifications.adminNtfyPanel.saved': 'Ustawienia admin Ntfy zapisane', + 'admin.notifications.adminNtfyPanel.test': 'Wyślij testowe Ntfy', + 'admin.notifications.adminNtfyPanel.testSuccess': 'Testowe Ntfy wysłane pomyślnie', + 'admin.notifications.adminNtfyPanel.testFailed': 'Wysyłanie testowego Ntfy nie powiodło się', + 'admin.notifications.adminNtfyPanel.alwaysOnHint': 'Admin Ntfy zawsze wysyła po skonfigurowaniu tematu', 'admin.notifications.adminNotificationsHint': 'Skonfiguruj, które kanały dostarczają powiadomienia admina (np. alerty o wersjach). Webhook wysyła automatycznie, gdy ustawiony jest URL webhooka admina.', 'admin.webhook.hint': 'Pozwól użytkownikom konfigurować własne adresy URL webhooka dla powiadomień (Discord, Slack itp.).', 'settings.notificationsDisabled': 'Powiadomienia nie są skonfigurowane.', @@ -1603,14 +1619,27 @@ const pl: Record = { 'settings.webhookUrl.label': 'URL webhooka', 'settings.webhookUrl.placeholder': 'https://discord.com/api/webhooks/...', 'settings.webhookUrl.hint': 'Wprowadź adres URL webhooka Discord, Slack lub własnego, aby otrzymywać powiadomienia.', - 'settings.webhookUrl.save': 'Zapisz', 'settings.webhookUrl.saved': 'URL webhooka zapisany', 'settings.webhookUrl.test': 'Testuj', 'settings.webhookUrl.testSuccess': 'Testowy webhook wysłany pomyślnie', 'settings.webhookUrl.testFailed': 'Wysyłanie testowego webhooka nie powiodło się', + 'settings.ntfyUrl.topicLabel': 'Temat Ntfy', + 'settings.ntfyUrl.topicPlaceholder': 'my-trek-alerts', + 'settings.ntfyUrl.serverLabel': 'URL serwera Ntfy (opcjonalne)', + 'settings.ntfyUrl.serverPlaceholder': 'https://ntfy.sh', + 'settings.ntfyUrl.hint': 'Wprowadź swój temat Ntfy, aby otrzymywać powiadomienia push. Pozostaw pole serwera puste, aby użyć domyślnego ustawienia skonfigurowanego przez administratora.', + 'settings.ntfyUrl.tokenLabel': 'Token dostępu (opcjonalne)', + 'settings.ntfyUrl.tokenHint': 'Wymagane dla tematów chronionych hasłem.', + 'settings.ntfyUrl.saved': 'Ustawienia Ntfy zapisane', + 'settings.ntfyUrl.test': 'Testuj', + 'settings.ntfyUrl.testSuccess': 'Testowe powiadomienie Ntfy wysłane pomyślnie', + 'settings.ntfyUrl.testFailed': 'Testowe powiadomienie Ntfy nie powiodło się', + 'settings.ntfyUrl.clearToken': 'Wyczyść', + 'settings.ntfyUrl.tokenCleared': 'Token dostępu wyczyszczony', 'settings.notificationPreferences.inapp': 'In-App', 'settings.notificationPreferences.webhook': 'Webhook', 'settings.notificationPreferences.email': 'Email', + 'settings.notificationPreferences.ntfy': 'Ntfy', 'settings.notificationsActive': 'Aktywny kanał', 'settings.notificationsManagedByAdmin': 'Zdarzenia konfigurowane przez administratora.', 'settings.mustChangePassword': 'Musisz zmienić hasło przed kontynuowaniem.', diff --git a/client/src/i18n/translations/ru.ts b/client/src/i18n/translations/ru.ts index 18373f55..63cb68ce 100644 --- a/client/src/i18n/translations/ru.ts +++ b/client/src/i18n/translations/ru.ts @@ -1759,14 +1759,27 @@ const ru: Record = { 'settings.webhookUrl.label': 'URL вебхука', 'settings.webhookUrl.placeholder': 'https://discord.com/api/webhooks/...', 'settings.webhookUrl.hint': 'Введите URL вашего вебхука Discord, Slack или пользовательского для получения уведомлений.', - 'settings.webhookUrl.save': 'Сохранить', 'settings.webhookUrl.saved': 'URL вебхука сохранён', 'settings.webhookUrl.test': 'Тест', 'settings.webhookUrl.testSuccess': 'Тестовый вебхук успешно отправлен', 'settings.webhookUrl.testFailed': 'Ошибка тестового вебхука', + 'settings.ntfyUrl.topicLabel': 'Тема Ntfy', + 'settings.ntfyUrl.topicPlaceholder': 'my-trek-alerts', + 'settings.ntfyUrl.serverLabel': 'URL сервера Ntfy (необязательно)', + 'settings.ntfyUrl.serverPlaceholder': 'https://ntfy.sh', + 'settings.ntfyUrl.hint': 'Введите тему Ntfy для получения push-уведомлений. Оставьте поле сервера пустым, чтобы использовать настройку по умолчанию, заданную администратором.', + 'settings.ntfyUrl.tokenLabel': 'Токен доступа (необязательно)', + 'settings.ntfyUrl.tokenHint': 'Требуется для тем, защищённых паролем.', + 'settings.ntfyUrl.saved': 'Настройки Ntfy сохранены', + 'settings.ntfyUrl.test': 'Тест', + 'settings.ntfyUrl.testSuccess': 'Тестовое уведомление Ntfy успешно отправлено', + 'settings.ntfyUrl.testFailed': 'Ошибка отправки тестового уведомления Ntfy', + 'settings.ntfyUrl.clearToken': 'Очистить', + 'settings.ntfyUrl.tokenCleared': 'Токен доступа очищен', 'settings.notificationPreferences.inapp': 'In-App', 'settings.notificationPreferences.webhook': 'Webhook', 'settings.notificationPreferences.email': 'Email', + 'settings.notificationPreferences.ntfy': 'Ntfy', 'admin.notifications.emailPanel.title': 'Email (SMTP)', 'admin.notifications.webhookPanel.title': 'Webhook', 'admin.notifications.inappPanel.title': 'In-App', @@ -1777,6 +1790,22 @@ const ru: Record = { 'admin.notifications.adminWebhookPanel.testSuccess': 'Тестовый вебхук успешно отправлен', 'admin.notifications.adminWebhookPanel.testFailed': 'Ошибка тестового вебхука', 'admin.notifications.adminWebhookPanel.alwaysOnHint': 'Вебхук администратора отправляется автоматически при наличии URL', + 'admin.notifications.ntfy': 'Ntfy', + 'admin.notifications.testNtfy': 'Отправить тестовое Ntfy', + 'admin.notifications.testNtfySuccess': 'Тестовое Ntfy успешно отправлено', + 'admin.notifications.testNtfyFailed': 'Ошибка отправки тестового Ntfy', + 'admin.notifications.adminNtfyPanel.title': 'Ntfy администратора', + 'admin.notifications.adminNtfyPanel.hint': 'Эта тема Ntfy используется исключительно для уведомлений администратора (например, оповещения о версиях). Она независима от тем пользователей и всегда отправляется при наличии настройки.', + 'admin.notifications.adminNtfyPanel.serverLabel': 'URL сервера Ntfy', + 'admin.notifications.adminNtfyPanel.serverPlaceholder': 'https://ntfy.sh', + 'admin.notifications.adminNtfyPanel.topicLabel': 'Тема администратора', + 'admin.notifications.adminNtfyPanel.topicPlaceholder': 'trek-admin-alerts', + 'admin.notifications.adminNtfyPanel.tokenLabel': 'Токен доступа (необязательно)', + 'admin.notifications.adminNtfyPanel.saved': 'Настройки Ntfy администратора сохранены', + 'admin.notifications.adminNtfyPanel.test': 'Отправить тестовое Ntfy', + 'admin.notifications.adminNtfyPanel.testSuccess': 'Тестовое Ntfy успешно отправлено', + 'admin.notifications.adminNtfyPanel.testFailed': 'Ошибка отправки тестового Ntfy', + 'admin.notifications.adminNtfyPanel.alwaysOnHint': 'Ntfy администратора всегда отправляется при наличии настроенной темы', 'admin.notifications.adminNotificationsHint': 'Настройте, какие каналы доставляют уведомления администратора (например, оповещения о версиях). Вебхук отправляется автоматически, если задан URL вебхука администратора.', 'admin.tabs.notifications': 'Уведомления', 'notifications.versionAvailable.title': 'Доступно обновление', diff --git a/client/src/i18n/translations/zh.ts b/client/src/i18n/translations/zh.ts index 3312da83..809cf136 100644 --- a/client/src/i18n/translations/zh.ts +++ b/client/src/i18n/translations/zh.ts @@ -1759,14 +1759,27 @@ const zh: Record = { 'settings.webhookUrl.label': 'Webhook URL', 'settings.webhookUrl.placeholder': 'https://discord.com/api/webhooks/...', 'settings.webhookUrl.hint': '输入您的 Discord、Slack 或自定义 Webhook URL 以接收通知。', - 'settings.webhookUrl.save': '保存', 'settings.webhookUrl.saved': 'Webhook URL 已保存', 'settings.webhookUrl.test': '测试', 'settings.webhookUrl.testSuccess': '测试 Webhook 发送成功', 'settings.webhookUrl.testFailed': '测试 Webhook 失败', + 'settings.ntfyUrl.topicLabel': 'Ntfy 主题', + 'settings.ntfyUrl.topicPlaceholder': 'my-trek-alerts', + 'settings.ntfyUrl.serverLabel': 'Ntfy 服务器 URL(可选)', + 'settings.ntfyUrl.serverPlaceholder': 'https://ntfy.sh', + 'settings.ntfyUrl.hint': '输入您的 Ntfy 主题以接收推送通知。将服务器留空以使用管理员配置的默认值。', + 'settings.ntfyUrl.tokenLabel': '访问令牌(可选)', + 'settings.ntfyUrl.tokenHint': '受密码保护的主题需要此项。', + 'settings.ntfyUrl.saved': 'Ntfy 设置已保存', + 'settings.ntfyUrl.test': '测试', + 'settings.ntfyUrl.testSuccess': '测试 Ntfy 通知发送成功', + 'settings.ntfyUrl.testFailed': '测试 Ntfy 通知失败', + 'settings.ntfyUrl.clearToken': '清除', + 'settings.ntfyUrl.tokenCleared': '访问令牌已清除', 'settings.notificationPreferences.inapp': 'In-App', 'settings.notificationPreferences.webhook': 'Webhook', 'settings.notificationPreferences.email': 'Email', + 'settings.notificationPreferences.ntfy': 'Ntfy', 'admin.notifications.emailPanel.title': 'Email (SMTP)', 'admin.notifications.webhookPanel.title': 'Webhook', 'admin.notifications.inappPanel.title': 'In-App', @@ -1777,6 +1790,22 @@ const zh: Record = { 'admin.notifications.adminWebhookPanel.testSuccess': '测试 Webhook 发送成功', 'admin.notifications.adminWebhookPanel.testFailed': '测试 Webhook 失败', 'admin.notifications.adminWebhookPanel.alwaysOnHint': '配置 URL 后管理员 Webhook 自动触发', + 'admin.notifications.ntfy': 'Ntfy', + 'admin.notifications.testNtfy': '发送测试 Ntfy', + 'admin.notifications.testNtfySuccess': '测试 Ntfy 发送成功', + 'admin.notifications.testNtfyFailed': '测试 Ntfy 失败', + 'admin.notifications.adminNtfyPanel.title': '管理员 Ntfy', + 'admin.notifications.adminNtfyPanel.hint': '此 Ntfy 主题专用于管理员通知(如版本更新提醒)。它与每用户主题相互独立,配置后始终触发。', + 'admin.notifications.adminNtfyPanel.serverLabel': 'Ntfy 服务器 URL', + 'admin.notifications.adminNtfyPanel.serverPlaceholder': 'https://ntfy.sh', + 'admin.notifications.adminNtfyPanel.topicLabel': '管理员主题', + 'admin.notifications.adminNtfyPanel.topicPlaceholder': 'trek-admin-alerts', + 'admin.notifications.adminNtfyPanel.tokenLabel': '访问令牌(可选)', + 'admin.notifications.adminNtfyPanel.saved': '管理员 Ntfy 设置已保存', + 'admin.notifications.adminNtfyPanel.test': '发送测试 Ntfy', + 'admin.notifications.adminNtfyPanel.testSuccess': '测试 Ntfy 发送成功', + 'admin.notifications.adminNtfyPanel.testFailed': '测试 Ntfy 失败', + 'admin.notifications.adminNtfyPanel.alwaysOnHint': '配置主题后管理员 Ntfy 始终触发', 'admin.notifications.adminNotificationsHint': '配置哪些渠道发送管理员通知(如版本更新提醒)。设置管理员 Webhook URL 后,Webhook 将自动触发。', 'admin.tabs.notifications': '通知', 'notifications.versionAvailable.title': '有可用更新', diff --git a/client/src/i18n/translations/zhTw.ts b/client/src/i18n/translations/zhTw.ts index 4e6acb6b..bda7dc91 100644 --- a/client/src/i18n/translations/zhTw.ts +++ b/client/src/i18n/translations/zhTw.ts @@ -186,15 +186,28 @@ const zhTw: Record = { 'settings.notificationPreferences.email': '電子郵件', 'settings.notificationPreferences.webhook': 'Webhook', 'settings.notificationPreferences.inapp': '應用程式內', + 'settings.notificationPreferences.ntfy': 'Ntfy', 'settings.notificationPreferences.noChannels': '未配置通知渠道。請聯絡管理員設定電子郵件或 Webhook 通知。', 'settings.webhookUrl.label': 'Webhook URL', 'settings.webhookUrl.placeholder': 'https://discord.com/api/webhooks/...', 'settings.webhookUrl.hint': '輸入您的 Discord、Slack 或自訂 Webhook URL 以接收通知。', - 'settings.webhookUrl.save': '儲存', 'settings.webhookUrl.saved': 'Webhook URL 已儲存', 'settings.webhookUrl.test': '測試', 'settings.webhookUrl.testSuccess': '測試 Webhook 傳送成功', 'settings.webhookUrl.testFailed': '測試 Webhook 傳送失敗', + 'settings.ntfyUrl.topicLabel': 'Ntfy 主題', + 'settings.ntfyUrl.topicPlaceholder': 'my-trek-alerts', + 'settings.ntfyUrl.serverLabel': 'Ntfy 伺服器 URL(選填)', + 'settings.ntfyUrl.serverPlaceholder': 'https://ntfy.sh', + 'settings.ntfyUrl.hint': '輸入您的 Ntfy 主題以接收推播通知。將伺服器留空以使用管理員設定的預設值。', + 'settings.ntfyUrl.tokenLabel': '存取權杖(選填)', + 'settings.ntfyUrl.tokenHint': '受密碼保護的主題需要此項目。', + 'settings.ntfyUrl.saved': 'Ntfy 設定已儲存', + 'settings.ntfyUrl.test': '測試', + 'settings.ntfyUrl.testSuccess': '測試 Ntfy 通知傳送成功', + 'settings.ntfyUrl.testFailed': '測試 Ntfy 通知失敗', + 'settings.ntfyUrl.clearToken': '清除', + 'settings.ntfyUrl.tokenCleared': '存取權杖已清除', 'settings.notificationsDisabled': '通知尚未配置。請聯絡管理員啟用電子郵件或 Webhook 通知。', 'settings.notificationsActive': '活躍頻道', 'settings.notificationsManagedByAdmin': '通知事件由管理員配置。', @@ -218,6 +231,22 @@ const zhTw: Record = { 'admin.notifications.adminWebhookPanel.testSuccess': '測試 Webhook 傳送成功', 'admin.notifications.adminWebhookPanel.testFailed': '測試 Webhook 傳送失敗', 'admin.notifications.adminWebhookPanel.alwaysOnHint': '配置 URL 後,管理員 Webhook 始終觸發', + 'admin.notifications.ntfy': 'Ntfy', + 'admin.notifications.testNtfy': '傳送測試 Ntfy', + 'admin.notifications.testNtfySuccess': '測試 Ntfy 傳送成功', + 'admin.notifications.testNtfyFailed': '測試 Ntfy 失敗', + 'admin.notifications.adminNtfyPanel.title': '管理員 Ntfy', + 'admin.notifications.adminNtfyPanel.hint': '此 Ntfy 主題專用於管理員通知(例如版本提醒)。它與每位使用者的主題分開,設定後始終會觸發。', + 'admin.notifications.adminNtfyPanel.serverLabel': 'Ntfy 伺服器 URL', + 'admin.notifications.adminNtfyPanel.serverPlaceholder': 'https://ntfy.sh', + 'admin.notifications.adminNtfyPanel.topicLabel': '管理員主題', + 'admin.notifications.adminNtfyPanel.topicPlaceholder': 'trek-admin-alerts', + 'admin.notifications.adminNtfyPanel.tokenLabel': '存取權杖(選填)', + 'admin.notifications.adminNtfyPanel.saved': '管理員 Ntfy 設定已儲存', + 'admin.notifications.adminNtfyPanel.test': '傳送測試 Ntfy', + 'admin.notifications.adminNtfyPanel.testSuccess': '測試 Ntfy 傳送成功', + 'admin.notifications.adminNtfyPanel.testFailed': '測試 Ntfy 失敗', + 'admin.notifications.adminNtfyPanel.alwaysOnHint': '設定主題後管理員 Ntfy 始終觸發', 'admin.notifications.adminNotificationsHint': '配置哪些渠道傳遞僅管理員通知(例如版本提醒)。', 'admin.smtp.title': '郵件與通知', 'admin.smtp.hint': '用於傳送電子郵件通知的 SMTP 配置。', diff --git a/client/src/pages/AdminPage.tsx b/client/src/pages/AdminPage.tsx index da4e28ef..76728cd2 100644 --- a/client/src/pages/AdminPage.tsx +++ b/client/src/pages/AdminPage.tsx @@ -66,6 +66,7 @@ const ADMIN_CHANNEL_LABEL_KEYS: Record = { inapp: 'settings.notificationPreferences.inapp', email: 'settings.notificationPreferences.email', webhook: 'settings.notificationPreferences.webhook', + ntfy: 'settings.notificationPreferences.ntfy', } function AdminNotificationsPanel({ t, toast }: { t: (k: string) => string; toast: ReturnType }) { @@ -78,7 +79,7 @@ function AdminNotificationsPanel({ t, toast }: { t: (k: string) => string; toast if (!matrix) return

Loading…

- const visibleChannels = (['inapp', 'email', 'webhook'] as const).filter(ch => { + const visibleChannels = (['inapp', 'email', 'webhook', 'ntfy'] as const).filter(ch => { if (!matrix.available_channels[ch]) return false return matrix.event_types.some((evt: string) => matrix.implemented_combos[evt]?.includes(ch)) }) @@ -1168,15 +1169,16 @@ export default function AdminPage(): React.ReactElement { const activeChans = rawChannels === 'none' ? [] : rawChannels.split(',').map((c: string) => c.trim()) const emailActive = activeChans.includes('email') const webhookActive = activeChans.includes('webhook') + const ntfyActive = activeChans.includes('ntfy') - const setChannels = async (email: boolean, webhook: boolean) => { - const chans = [email && 'email', webhook && 'webhook'].filter(Boolean).join(',') || 'none' + const setChannels = async (email: boolean, webhook: boolean, ntfy: boolean) => { + const chans = [email && 'email', webhook && 'webhook', ntfy && 'ntfy'].filter(Boolean).join(',') || 'none' setSmtpValues(prev => ({ ...prev, notification_channels: chans })) try { await authApi.updateAppSettings({ notification_channels: chans }) } catch { // Revert state on failure - const reverted = [emailActive && 'email', webhookActive && 'webhook'].filter(Boolean).join(',') || 'none' + const reverted = [emailActive && 'email', webhookActive && 'webhook', ntfyActive && 'ntfy'].filter(Boolean).join(',') || 'none' setSmtpValues(prev => ({ ...prev, notification_channels: reverted })) toast.error(t('common.error')) } @@ -1207,7 +1209,7 @@ export default function AdminPage(): React.ReactElement {

{t('admin.smtp.hint')}

+ + + {/* In-App Panel */}
@@ -1358,6 +1378,89 @@ export default function AdminPage(): React.ReactElement {
+ {/* Admin Ntfy Panel */} +
+
+

{t('admin.notifications.adminNtfyPanel.title')}

+

{t('admin.notifications.adminNtfyPanel.hint')}

+
+
+ {smtpLoaded && ( + <> +
+ + setSmtpValues(prev => ({ ...prev, admin_ntfy_server: e.target.value }))} + placeholder={t('admin.notifications.adminNtfyPanel.serverPlaceholder')} + className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent" + /> +
+
+ + setSmtpValues(prev => ({ ...prev, admin_ntfy_topic: e.target.value }))} + placeholder={t('admin.notifications.adminNtfyPanel.topicPlaceholder')} + className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent" + /> +
+
+ + setSmtpValues(prev => ({ ...prev, admin_ntfy_token: e.target.value }))} + placeholder={smtpValues.admin_ntfy_token === '••••••••' ? '••••••••' : ''} + className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent" + /> +
+ + )} +
+
+ + +
+
+
diff --git a/client/tests/helpers/msw/handlers/notifications.ts b/client/tests/helpers/msw/handlers/notifications.ts index 463f3e44..f009cee0 100644 --- a/client/tests/helpers/msw/handlers/notifications.ts +++ b/client/tests/helpers/msw/handlers/notifications.ts @@ -61,6 +61,10 @@ export const notificationHandlers = [ return HttpResponse.json({ success: true }); }), + http.post('/api/notifications/test-ntfy', async () => { + return HttpResponse.json({ success: true }); + }), + http.post('/api/notifications/in-app/:id/respond', async ({ request, params }) => { const body = await request.json() as { response: string }; return HttpResponse.json({ diff --git a/server/src/routes/notifications.ts b/server/src/routes/notifications.ts index 493561bd..9f8e7c27 100644 --- a/server/src/routes/notifications.ts +++ b/server/src/routes/notifications.ts @@ -1,7 +1,7 @@ import express, { Request, Response } from 'express'; import { authenticate } from '../middleware/auth'; import { AuthRequest } from '../types'; -import { testSmtp, testWebhook, getAdminWebhookUrl, getUserWebhookUrl } from '../services/notifications'; +import { testSmtp, testWebhook, testNtfy, getAdminWebhookUrl, getUserWebhookUrl, getUserNtfyConfig, getAdminNtfyConfig } from '../services/notifications'; import { getNotifications, getUnreadCount, @@ -47,6 +47,26 @@ router.post('/test-webhook', authenticate, async (req: Request, res: Response) = res.json(await testWebhook(url)); }); +router.post('/test-ntfy', authenticate, async (req: Request, res: Response) => { + const authReq = req as AuthRequest; + const { topic, server, token } = req.body as { topic?: string; server?: string; token?: string }; + + // Always load saved config for fallbacks (token may be masked or absent in request) + const userCfg = getUserNtfyConfig(authReq.user.id); + const adminCfg = getAdminNtfyConfig(); + + const resolvedTopic = topic || userCfg?.topic || undefined; + const resolvedServer = server || userCfg?.server || adminCfg.server || undefined; + // Reuse saved token when request sends null, empty, or the masked placeholder + const resolvedToken = (token && token !== '••••••••') + ? token + : (userCfg?.token ?? adminCfg.token ?? null); + + if (!resolvedTopic) return res.status(400).json({ error: 'No ntfy topic configured' }); + + res.json(await testNtfy({ topic: resolvedTopic, server: resolvedServer ?? null, token: resolvedToken })); +}); + // ── In-app notifications ────────────────────────────────────────────────────── // GET /in-app — list notifications (paginated) diff --git a/server/src/services/authService.ts b/server/src/services/authService.ts index 5d475306..e15395d3 100644 --- a/server/src/services/authService.ts +++ b/server/src/services/authService.ts @@ -30,7 +30,7 @@ const MFA_BACKUP_CODE_COUNT = 10; const ADMIN_SETTINGS_KEYS = [ 'allow_registration', 'allowed_file_types', 'require_mfa', 'smtp_host', 'smtp_port', 'smtp_user', 'smtp_pass', 'smtp_from', 'smtp_skip_tls_verify', - 'notification_channels', 'admin_webhook_url', + 'notification_channels', 'admin_webhook_url', 'admin_ntfy_server', 'admin_ntfy_topic', 'admin_ntfy_token', 'password_login', 'password_registration', 'oidc_login', 'oidc_registration', ]; @@ -714,7 +714,7 @@ export function getAppSettings(userId: number): { error?: string; status?: numbe const result: Record = {}; for (const key of ADMIN_SETTINGS_KEYS) { const row = db.prepare("SELECT value FROM app_settings WHERE key = ?").get(key) as { value: string } | undefined; - if (row) result[key] = (key === 'smtp_pass' || key === 'admin_webhook_url') ? '••••••••' : row.value; + if (row) result[key] = (key === 'smtp_pass' || key === 'admin_webhook_url' || key === 'admin_ntfy_token') ? '••••••••' : row.value; } return { data: result }; } @@ -768,6 +768,8 @@ export function updateAppSettings( if (key === 'smtp_pass') val = encrypt_api_key(val); if (key === 'admin_webhook_url' && val === '••••••••') continue; if (key === 'admin_webhook_url' && val) val = maybe_encrypt_api_key(val) ?? val; + if (key === 'admin_ntfy_token' && val === '••••••••') continue; + if (key === 'admin_ntfy_token' && val) val = maybe_encrypt_api_key(val) ?? val; db.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)").run(key, val); } } @@ -778,6 +780,7 @@ export function updateAppSettings( const smtpChanged = changedKeys.some(k => k.startsWith('smtp_')); if (changedKeys.includes('notification_channels')) summary.notification_channels = body.notification_channels; if (changedKeys.includes('admin_webhook_url')) summary.admin_webhook_url_updated = true; + if (changedKeys.some(k => k.startsWith('admin_ntfy_'))) summary.admin_ntfy_updated = true; if (smtpChanged) summary.smtp_settings_updated = true; if (changedKeys.includes('allow_registration')) summary.allow_registration = body.allow_registration; if (changedKeys.includes('allowed_file_types')) summary.allowed_file_types_updated = true; diff --git a/server/src/services/notificationPreferencesService.ts b/server/src/services/notificationPreferencesService.ts index 5b7ef73f..144d9c67 100644 --- a/server/src/services/notificationPreferencesService.ts +++ b/server/src/services/notificationPreferencesService.ts @@ -3,7 +3,7 @@ import { decrypt_api_key } from './apiKeyCrypto'; // ── Types ────────────────────────────────────────────────────────────────── -export type NotifChannel = 'email' | 'webhook' | 'inapp'; +export type NotifChannel = 'email' | 'webhook' | 'inapp' | 'ntfy'; export type NotifEventType = | 'trip_invite' @@ -20,19 +20,20 @@ export interface AvailableChannels { email: boolean; webhook: boolean; inapp: boolean; + ntfy: boolean; } // Which channels are implemented for each event type. // Only implemented combos show toggles in the user preferences UI. const IMPLEMENTED_COMBOS: Record = { - trip_invite: ['inapp', 'email', 'webhook'], - booking_change: ['inapp', 'email', 'webhook'], - trip_reminder: ['inapp', 'email', 'webhook'], - vacay_invite: ['inapp', 'email', 'webhook'], - photos_shared: ['inapp', 'email', 'webhook'], - collab_message: ['inapp', 'email', 'webhook'], - packing_tagged: ['inapp', 'email', 'webhook'], - version_available: ['inapp', 'email', 'webhook'], + trip_invite: ['inapp', 'email', 'webhook', 'ntfy'], + booking_change: ['inapp', 'email', 'webhook', 'ntfy'], + trip_reminder: ['inapp', 'email', 'webhook', 'ntfy'], + vacay_invite: ['inapp', 'email', 'webhook', 'ntfy'], + photos_shared: ['inapp', 'email', 'webhook', 'ntfy'], + collab_message: ['inapp', 'email', 'webhook', 'ntfy'], + packing_tagged: ['inapp', 'email', 'webhook', 'ntfy'], + version_available: ['inapp', 'email', 'webhook', 'ntfy'], synology_session_cleared: ['inapp'], }; @@ -55,7 +56,7 @@ function getAppSetting(key: string): string | null { export function getActiveChannels(): NotifChannel[] { const raw = getAppSetting('notification_channels') || getAppSetting('notification_channel') || 'none'; if (raw === 'none') return []; - return raw.split(',').map(c => c.trim()).filter((c): c is NotifChannel => c === 'email' || c === 'webhook'); + return raw.split(',').map(c => c.trim()).filter((c): c is NotifChannel => c === 'email' || c === 'webhook' || c === 'ntfy'); } /** @@ -64,8 +65,8 @@ export function getActiveChannels(): NotifChannel[] { */ export function getAvailableChannels(): AvailableChannels { const hasSmtp = !!(process.env.SMTP_HOST || getAppSetting('smtp_host')); - const hasWebhook = getActiveChannels().includes('webhook'); - return { email: hasSmtp, webhook: hasWebhook, inapp: true }; + const activeChannels = getActiveChannels(); + return { email: hasSmtp, webhook: activeChannels.includes('webhook'), ntfy: activeChannels.includes('ntfy'), inapp: true }; } // ── Per-user preference checks ───────────────────────────────────────────── @@ -115,8 +116,8 @@ export function getPreferencesMatrix(userId: number, userRole: string, scope: 'u const channels = IMPLEMENTED_COMBOS[eventType]; preferences[eventType] = {}; for (const channel of channels) { - // Admin-scoped events use global settings for email/webhook - if (scope === 'admin' && ADMIN_SCOPED_EVENTS.has(eventType) && (channel === 'email' || channel === 'webhook')) { + // Admin-scoped events use global settings for email/webhook/ntfy + if (scope === 'admin' && ADMIN_SCOPED_EVENTS.has(eventType) && (channel === 'email' || channel === 'webhook' || channel === 'ntfy')) { preferences[eventType]![channel] = getAdminGlobalPref(eventType, channel); } else { preferences[eventType]![channel] = stored[eventType]?.[channel] ?? true; @@ -134,12 +135,14 @@ export function getPreferencesMatrix(userId: number, userRole: string, scope: 'u if (scope === 'admin') { const hasSmtp = !!(process.env.SMTP_HOST || getAppSetting('smtp_host')); const hasAdminWebhook = !!(getAppSetting('admin_webhook_url')); - available_channels = { email: hasSmtp, webhook: hasAdminWebhook, inapp: true }; + const hasAdminNtfy = !!(getAppSetting('admin_ntfy_topic')); + available_channels = { email: hasSmtp, webhook: hasAdminWebhook, ntfy: hasAdminNtfy, inapp: true }; } else { const activeChannels = getActiveChannels(); available_channels = { email: activeChannels.includes('email'), webhook: activeChannels.includes('webhook'), + ntfy: activeChannels.includes('ntfy'), inapp: true, }; } @@ -154,19 +157,19 @@ export function getPreferencesMatrix(userId: number, userRole: string, scope: 'u // ── Admin global preferences (stored in app_settings) ───────────────────── -const ADMIN_GLOBAL_CHANNELS: NotifChannel[] = ['email', 'webhook']; +const ADMIN_GLOBAL_CHANNELS: NotifChannel[] = ['email', 'webhook', 'ntfy']; /** * Returns the global admin preference for an event+channel. * Stored in app_settings as `admin_notif_pref_{event}_{channel}`. * Defaults to true (enabled) when no row exists. */ -export function getAdminGlobalPref(event: NotifEventType, channel: 'email' | 'webhook'): boolean { +export function getAdminGlobalPref(event: NotifEventType, channel: 'email' | 'webhook' | 'ntfy'): boolean { const val = getAppSetting(`admin_notif_pref_${event}_${channel}`); return val !== '0'; } -function setAdminGlobalPref(event: NotifEventType, channel: 'email' | 'webhook', enabled: boolean): void { +function setAdminGlobalPref(event: NotifEventType, channel: 'email' | 'webhook' | 'ntfy', enabled: boolean): void { db.prepare('INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)').run( `admin_notif_pref_${event}_${channel}`, enabled ? '1' : '0' @@ -250,7 +253,7 @@ export function setAdminPreferences( for (const [eventType, channels] of Object.entries(globalPrefs)) { if (!channels) continue; for (const [channel, enabled] of Object.entries(channels)) { - setAdminGlobalPref(eventType as NotifEventType, channel as 'email' | 'webhook', enabled); + setAdminGlobalPref(eventType as NotifEventType, channel as 'email' | 'webhook' | 'ntfy', enabled); } } diff --git a/server/src/services/notificationService.ts b/server/src/services/notificationService.ts index 243faaf7..8a8a94cf 100644 --- a/server/src/services/notificationService.ts +++ b/server/src/services/notificationService.ts @@ -7,15 +7,20 @@ import { isSmtpConfigured, ADMIN_SCOPED_EVENTS, type NotifEventType, + type NotifChannel, } from './notificationPreferencesService'; import { getEventText, sendEmail, sendWebhook, + sendNtfy, getUserEmail, getUserLanguage, getUserWebhookUrl, getAdminWebhookUrl, + getUserNtfyConfig, + getAdminNtfyConfig, + resolveNtfyUrl, getAppUrl, } from './notifications'; import { @@ -270,6 +275,19 @@ export async function send(payload: NotificationPayload): Promise { } } + // ── Ntfy (per-user) — skip for admin-scoped events (handled globally below) ── + if (!ADMIN_SCOPED_EVENTS.has(event) && activeChannels.includes('ntfy') && isEnabledForEvent(recipientId, event, 'ntfy' as NotifChannel)) { + const userNtfyCfg = getUserNtfyConfig(recipientId); + const adminNtfyCfg = getAdminNtfyConfig(); + const ntfyUrl = resolveNtfyUrl(adminNtfyCfg, userNtfyCfg); + if (ntfyUrl) { + const lang = getUserLanguage(recipientId); + const { title, body } = getEventText(lang, event, params); + const token = userNtfyCfg?.token ?? adminNtfyCfg.token; + promises.push(sendNtfy(ntfyUrl, token, { event, title, body, link: fullLink })); + } + } + const results = await Promise.allSettled(promises); for (const result of results) { if (result.status === 'rejected') { @@ -288,4 +306,16 @@ export async function send(payload: NotificationPayload): Promise { }); } } + + // ── Admin ntfy (scope: admin) — global, respects global pref ───────── + if (scope === 'admin' && getAdminGlobalPref(event, 'ntfy')) { + const adminNtfyCfg = getAdminNtfyConfig(); + const adminNtfyUrl = resolveNtfyUrl(adminNtfyCfg, null); + if (adminNtfyUrl) { + const { title, body } = getEventText('en', event, params); + await sendNtfy(adminNtfyUrl, adminNtfyCfg.token, { event, title, body, link: fullLink }).catch((err: unknown) => { + logError(`notificationService.send admin ntfy failed event=${event}: ${err instanceof Error ? err.message : err}`); + }); + } + } } diff --git a/server/src/services/notifications.ts b/server/src/services/notifications.ts index ca0ad885..0773a279 100644 --- a/server/src/services/notifications.ts +++ b/server/src/services/notifications.ts @@ -442,3 +442,134 @@ export async function testWebhook(url: string): Promise<{ success: boolean; erro } } +// ── Ntfy ────────────────────────────────────────────────────────────────── + +export interface NtfyConfig { + server: string | null; + topic: string | null; + token: string | null; +} + +/** Priority and tags mapped to each notification event type. */ +const NTFY_EVENT_META: Partial> = { + trip_invite: { priority: 4, tags: ['loudspeaker'] }, + booking_change: { priority: 3, tags: ['calendar'] }, + trip_reminder: { priority: 4, tags: ['bell', 'alarm_clock'] }, + vacay_invite: { priority: 4, tags: ['palm_tree'] }, + photos_shared: { priority: 3, tags: ['camera'] }, + collab_message: { priority: 3, tags: ['speech_balloon'] }, + packing_tagged: { priority: 3, tags: ['luggage'] }, + version_available: { priority: 4, tags: ['package'] }, + synology_session_cleared: { priority: 3, tags: ['warning'] }, +}; +const NTFY_DEFAULT_META = { priority: 3 as const, tags: [] as string[] }; + +export function getUserNtfyConfig(userId: number): NtfyConfig | null { + const rows = db.prepare( + "SELECT key, value FROM settings WHERE user_id = ? AND key IN ('ntfy_topic', 'ntfy_server', 'ntfy_token')" + ).all(userId) as { key: string; value: string }[]; + if (rows.length === 0) return null; + const map: Record = {}; + for (const r of rows) map[r.key] = r.value; + return { + topic: map['ntfy_topic'] || null, + server: map['ntfy_server'] || null, + token: map['ntfy_token'] ? decrypt_api_key(map['ntfy_token']) : null, + }; +} + +export function getAdminNtfyConfig(): NtfyConfig { + const topic = getAppSetting('admin_ntfy_topic') || null; + const server = getAppSetting('admin_ntfy_server') || null; + const rawToken = getAppSetting('admin_ntfy_token') || null; + return { + topic, + server, + token: rawToken ? decrypt_api_key(rawToken) : null, + }; +} + +/** + * Resolve the ntfy POST URL from admin base config + user override. + * Returns null if topic cannot be determined. + */ +export function resolveNtfyUrl(adminCfg: NtfyConfig, userCfg: NtfyConfig | null): string | null { + const topic = userCfg?.topic || adminCfg.topic; + if (!topic) return null; + const base = (userCfg?.server || adminCfg.server || 'https://ntfy.sh').replace(/\/+$/, ''); + return `${base}/${encodeURIComponent(topic)}`; +} + +export function isNtfyConfiguredForUser(userId: number): boolean { + const cfg = getUserNtfyConfig(userId); + return !!(cfg?.topic); +} + +export function isNtfyConfiguredAdmin(): boolean { + return !!(getAppSetting('admin_ntfy_topic')); +} + +export async function sendNtfy( + url: string, + token: string | null, + payload: { event: string; title: string; body: string; link?: string }, +): Promise { + if (!url) return false; + + const ssrf = await checkSsrf(url); + if (!ssrf.allowed) { + logError(`Ntfy blocked by SSRF guard event=${payload.event} url=${url} reason=${ssrf.error}`); + return false; + } + + const meta = NTFY_EVENT_META[payload.event as NotifEventType] ?? NTFY_DEFAULT_META; + + // ntfy header-based API: POST to topic URL, body = plain text message, metadata in headers + const headers: Record = { + 'Title': payload.title, + 'Priority': String(meta.priority), + }; + if (meta.tags.length > 0) headers['Tags'] = meta.tags.join(','); + if (payload.link) headers['Click'] = payload.link; + if (token) headers['Authorization'] = `Bearer ${token}`; + + try { + const res = await fetch(url, { + method: 'POST', + headers, + body: payload.body, + signal: AbortSignal.timeout(10000), + dispatcher: createPinnedDispatcher(ssrf.resolvedIp!), + } as any); + + if (!res.ok) { + const errBody = await res.text().catch(() => ''); + logError(`Ntfy HTTP ${res.status}: ${errBody}`); + return false; + } + + logInfo(`Ntfy sent event=${payload.event}`); + logDebug(`Ntfy url=${url} priority=${meta.priority} tags=${meta.tags.join(',')}`); + return true; + } catch (err) { + logError(`Ntfy failed event=${payload.event}: ${err instanceof Error ? err.message : err}`); + return false; + } +} + +export async function testNtfy(cfg: { topic: string; server?: string | null; token?: string | null }): Promise<{ success: boolean; error?: string }> { + const adminCfg = getAdminNtfyConfig(); + const url = resolveNtfyUrl(adminCfg, { topic: cfg.topic, server: cfg.server ?? null, token: cfg.token ?? null }); + if (!url) return { success: false, error: 'Could not resolve ntfy URL — missing topic' }; + try { + const sent = await sendNtfy(url, cfg.token ?? null, { + event: 'test', + title: 'Test Notification', + body: 'This is a test notification from TREK. If you received this, your ntfy configuration is working correctly.', + }); + return sent ? { success: true } : { success: false, error: 'Failed to send ntfy notification' }; + } catch (err) { + return { success: false, error: err instanceof Error ? err.message : 'Unknown error' }; + } +} + diff --git a/server/src/services/settingsService.ts b/server/src/services/settingsService.ts index 12a0388e..e8b22af4 100644 --- a/server/src/services/settingsService.ts +++ b/server/src/services/settingsService.ts @@ -1,7 +1,7 @@ import { db } from '../db/database'; import { maybe_encrypt_api_key } from './apiKeyCrypto'; -const ENCRYPTED_SETTING_KEYS = new Set(['webhook_url']); +const ENCRYPTED_SETTING_KEYS = new Set(['webhook_url', 'ntfy_token']); export function getUserSettings(userId: number): Record { const rows = db.prepare('SELECT key, value FROM settings WHERE user_id = ?').all(userId) as { key: string; value: string }[]; diff --git a/server/tests/integration/notifications.test.ts b/server/tests/integration/notifications.test.ts index 28e38490..0eee9763 100644 --- a/server/tests/integration/notifications.test.ts +++ b/server/tests/integration/notifications.test.ts @@ -348,6 +348,43 @@ describe('Notification test endpoints', () => { expect(res.status).toBe(200); expect(res.body).toHaveProperty('success'); }); + + it('NOTIF-007 — POST /api/notifications/test-ntfy returns 400 when no topic configured', async () => { + const { user } = createUser(testDb); + + const res = await request(app) + .post('/api/notifications/test-ntfy') + .set('Cookie', authCookie(user.id)) + .send({}); + + expect(res.status).toBe(400); + expect(res.body).toHaveProperty('error'); + }); + + it('NOTIF-008 — POST /api/notifications/test-ntfy with explicit topic returns 200', async () => { + const { user } = createUser(testDb); + + const res = await request(app) + .post('/api/notifications/test-ntfy') + .set('Cookie', authCookie(user.id)) + .send({ topic: 'trek-integration-test-topic' }); + + expect(res.status).toBe(200); + expect(res.body).toHaveProperty('success'); + }); + + it('NOTIF-009 — POST /api/notifications/test-ntfy falls back to user saved topic', async () => { + const { user } = createUser(testDb); + testDb.prepare("INSERT OR REPLACE INTO settings (user_id, key, value) VALUES (?, 'ntfy_topic', 'saved-user-topic')").run(user.id); + + const res = await request(app) + .post('/api/notifications/test-ntfy') + .set('Cookie', authCookie(user.id)) + .send({}); + + expect(res.status).toBe(200); + expect(res.body).toHaveProperty('success'); + }); }); // ───────────────────────────────────────────────────────────────────────────── diff --git a/server/tests/unit/services/notificationPreferencesService.test.ts b/server/tests/unit/services/notificationPreferencesService.test.ts index 6126ff8d..3cca642f 100644 --- a/server/tests/unit/services/notificationPreferencesService.test.ts +++ b/server/tests/unit/services/notificationPreferencesService.test.ts @@ -153,14 +153,15 @@ describe('getPreferencesMatrix', () => { expect(available_channels.email).toBe(false); }); - it('NPREF-011 — implemented_combos maps version_available to [inapp, email, webhook]', () => { + it('NPREF-011 — implemented_combos maps version_available to [inapp, email, webhook, ntfy]', () => { const { user } = createAdmin(testDb); const { implemented_combos } = getPreferencesMatrix(user.id, 'admin', 'admin'); - expect(implemented_combos['version_available']).toEqual(['inapp', 'email', 'webhook']); - // All events now support all three channels + expect(implemented_combos['version_available']).toEqual(['inapp', 'email', 'webhook', 'ntfy']); + // All events now support all four channels expect(implemented_combos['trip_invite']).toContain('inapp'); expect(implemented_combos['trip_invite']).toContain('email'); expect(implemented_combos['trip_invite']).toContain('webhook'); + expect(implemented_combos['trip_invite']).toContain('ntfy'); }); }); diff --git a/server/tests/unit/services/notificationService.test.ts b/server/tests/unit/services/notificationService.test.ts index 3f9eeba2..bbef6ef6 100644 --- a/server/tests/unit/services/notificationService.test.ts +++ b/server/tests/unit/services/notificationService.test.ts @@ -458,3 +458,72 @@ describe('send() — channel failure resilience', () => { expect(countAllNotifications()).toBe(1); }); }); + +// ── Ntfy dispatch ───────────────────────────────────────────────────────────── + +function setUserNtfyTopic(userId: number, topic = 'my-trek-topic'): void { + testDb.prepare("INSERT OR REPLACE INTO settings (user_id, key, value) VALUES (?, 'ntfy_topic', ?)").run(userId, topic); +} + +function setAdminNtfyTopic(topic = 'trek-admin-alerts'): void { + setAppSetting(testDb, 'admin_ntfy_topic', topic); +} + +describe('send() — ntfy channel dispatch', () => { + beforeEach(() => { + fetchMock.mockResolvedValue({ ok: true, text: async () => '' }); + }); + + it('NTFY-SVCB-001 — ntfy fires when channel active and user has topic configured', async () => { + const { user } = createUser(testDb); + setUserNtfyTopic(user.id); + setNotificationChannels(testDb, 'ntfy'); + const tripId = (testDb.prepare('INSERT INTO trips (title, user_id) VALUES (?, ?)').run('Tokyo', user.id)).lastInsertRowid as number; + + await send({ event: 'trip_invite', actorId: null, scope: 'user', targetId: user.id, params: { trip: 'Tokyo', actor: 'Alice', invitee: 'Bob', tripId: String(tripId) } }); + + const ntfyCalls = fetchMock.mock.calls.filter(([url]: [string]) => url.includes('ntfy.sh')); + expect(ntfyCalls.length).toBeGreaterThan(0); + // Header-based API: metadata in headers, body = plain text + expect(ntfyCalls[0][1].headers['Priority']).toBe('4'); // trip_invite = high priority + expect(ntfyCalls[0][1].headers['Tags']).toContain('loudspeaker'); + }); + + it('NTFY-SVCB-002 — ntfy skips when channel not in active channels', async () => { + const { user } = createUser(testDb); + setUserNtfyTopic(user.id); + setNotificationChannels(testDb, 'none'); + + fetchMock.mockClear(); + await send({ event: 'trip_invite', actorId: null, scope: 'user', targetId: user.id, params: { trip: 'Paris', actor: 'Alice', invitee: 'Bob', tripId: '1' } }); + + const ntfyCalls = fetchMock.mock.calls.filter(([url]: [string]) => url.includes('ntfy.sh')); + expect(ntfyCalls.length).toBe(0); + }); + + it('NTFY-SVCB-003 — ntfy skips when user has no topic configured', async () => { + const { user } = createUser(testDb); + setNotificationChannels(testDb, 'ntfy'); + // No ntfy_topic set, but no admin_ntfy_server either — resolveNtfyUrl returns null + + fetchMock.mockClear(); + await send({ event: 'trip_invite', actorId: null, scope: 'user', targetId: user.id, params: { trip: 'Rome', actor: 'Alice', invitee: 'Bob', tripId: '1' } }); + + const ntfyCalls = fetchMock.mock.calls.filter(([url]: [string]) => url.includes('ntfy.sh')); + expect(ntfyCalls.length).toBe(0); + }); + + it('NTFY-SVCB-004 — admin-scoped version_available fires admin ntfy topic', async () => { + createAdmin(testDb); + setAdminNtfyTopic(); + setNotificationChannels(testDb, 'none'); + + fetchMock.mockClear(); + await send({ event: 'version_available', actorId: null, scope: 'admin', targetId: 0, params: { version: '3.0.0' } }); + + const ntfyCalls = fetchMock.mock.calls.filter(([url]: [string]) => url.includes('ntfy.sh')); + expect(ntfyCalls.length).toBeGreaterThan(0); + expect(ntfyCalls[0][1].headers['Priority']).toBe('4'); // version_available = high priority + expect(ntfyCalls[0][1].headers['Tags']).toContain('package'); + }); +}); diff --git a/server/tests/unit/services/notifications.test.ts b/server/tests/unit/services/notifications.test.ts index acf7c3d4..6744dd62 100644 --- a/server/tests/unit/services/notifications.test.ts +++ b/server/tests/unit/services/notifications.test.ts @@ -24,7 +24,7 @@ vi.mock('../../../src/utils/ssrfGuard', () => ({ createPinnedDispatcher: vi.fn(() => ({})), })); -import { getEventText, buildEmailHtml, buildWebhookBody, sendWebhook } from '../../../src/services/notifications'; +import { getEventText, buildEmailHtml, buildWebhookBody, sendWebhook, sendNtfy, resolveNtfyUrl, type NtfyConfig } from '../../../src/services/notifications'; import { checkSsrf } from '../../../src/utils/ssrfGuard'; import { logError } from '../../../src/services/auditLog'; @@ -319,3 +319,140 @@ describe('sendWebhook SSRF protection (SEC-017)', () => { }); afterAll(() => vi.unstubAllGlobals()); + +// ── resolveNtfyUrl ──────────────────────────────────────────────────────────── + +describe('resolveNtfyUrl', () => { + const adminCfg: NtfyConfig = { server: 'https://ntfy.sh', topic: 'admin-topic', token: null }; + + it('uses admin server + admin topic when no user config', () => { + expect(resolveNtfyUrl(adminCfg, null)).toBe('https://ntfy.sh/admin-topic'); + }); + + it('uses user topic over admin topic', () => { + const user: NtfyConfig = { server: null, topic: 'my-topic', token: null }; + expect(resolveNtfyUrl(adminCfg, user)).toBe('https://ntfy.sh/my-topic'); + }); + + it('uses user server override', () => { + const user: NtfyConfig = { server: 'https://ntfy.example.com', topic: 'my-topic', token: null }; + expect(resolveNtfyUrl(adminCfg, user)).toBe('https://ntfy.example.com/my-topic'); + }); + + it('strips trailing slash from server', () => { + const admin: NtfyConfig = { server: 'https://ntfy.sh/', topic: 'alerts', token: null }; + expect(resolveNtfyUrl(admin, null)).toBe('https://ntfy.sh/alerts'); + }); + + it('returns null when no topic in admin or user config', () => { + const noTopic: NtfyConfig = { server: 'https://ntfy.sh', topic: null, token: null }; + expect(resolveNtfyUrl(noTopic, null)).toBeNull(); + }); + + it('falls back to https://ntfy.sh when no server configured', () => { + const noServer: NtfyConfig = { server: null, topic: 'my-topic', token: null }; + expect(resolveNtfyUrl(noServer, null)).toBe('https://ntfy.sh/my-topic'); + }); +}); + +// ── sendNtfy ───────────────────────────────────────────────────────────────── + +describe('sendNtfy', () => { + const ntfyUrl = 'https://ntfy.sh/trek-test'; + const payload = { event: 'trip_invite', title: 'Test Title', body: 'Test body' }; + + beforeEach(() => { + vi.mocked(logError).mockClear(); + (globalThis.fetch as ReturnType).mockClear(); + vi.mocked(checkSsrf).mockResolvedValue({ allowed: true, isPrivate: false, resolvedIp: '1.2.3.4' }); + }); + + it('NTFY-001 — sends POST to topic URL with plain text body and metadata in headers', async () => { + const mockFetch = globalThis.fetch as unknown as ReturnType; + mockFetch.mockResolvedValueOnce({ ok: true, text: async () => '' } as never); + + const result = await sendNtfy(ntfyUrl, null, payload); + expect(result).toBe(true); + expect(mockFetch).toHaveBeenCalledOnce(); + + const [calledUrl, calledOpts] = mockFetch.mock.calls[0]; + expect(calledUrl).toBe(ntfyUrl); + // Body should be plain text, not JSON + expect(calledOpts.body).toBe('Test body'); + // Title, Priority, Tags go in headers + expect(calledOpts.headers['Title']).toBe('Test Title'); + expect(calledOpts.headers['Priority']).toBe('4'); // trip_invite maps to priority 4 + expect(calledOpts.headers['Tags']).toContain('loudspeaker'); + }); + + it('NTFY-002 — attaches Bearer token when token provided', async () => { + const mockFetch = globalThis.fetch as unknown as ReturnType; + mockFetch.mockResolvedValueOnce({ ok: true, text: async () => '' } as never); + + await sendNtfy(ntfyUrl, 'my-secret-token', payload); + + const [, calledOpts] = mockFetch.mock.calls[0]; + expect(calledOpts.headers['Authorization']).toBe('Bearer my-secret-token'); + }); + + it('NTFY-003 — no Authorization header when token is null', async () => { + const mockFetch = globalThis.fetch as unknown as ReturnType; + mockFetch.mockResolvedValueOnce({ ok: true, text: async () => '' } as never); + + await sendNtfy(ntfyUrl, null, payload); + + const [, calledOpts] = mockFetch.mock.calls[0]; + expect(calledOpts.headers['Authorization']).toBeUndefined(); + }); + + it('NTFY-004 — includes Click header when link is provided', async () => { + const mockFetch = globalThis.fetch as unknown as ReturnType; + mockFetch.mockResolvedValueOnce({ ok: true, text: async () => '' } as never); + + await sendNtfy(ntfyUrl, null, { ...payload, link: 'https://trek.example.com/trips/5' }); + + const [, calledOpts] = mockFetch.mock.calls[0]; + expect(calledOpts.headers['Click']).toBe('https://trek.example.com/trips/5'); + }); + + it('NTFY-005 — SSRF guard blocks private URL and returns false', async () => { + vi.mocked(checkSsrf).mockResolvedValueOnce({ + allowed: false, isPrivate: true, resolvedIp: '192.168.1.1', + error: 'Requests to private/internal network addresses are not allowed', + }); + + const result = await sendNtfy('http://192.168.1.1/ntfy', null, payload); + expect(result).toBe(false); + expect(vi.mocked(logError)).toHaveBeenCalledWith(expect.stringContaining('SSRF')); + expect(globalThis.fetch as ReturnType).not.toHaveBeenCalled(); + }); + + it('NTFY-006 — HTTP non-2xx response returns false and logs error', async () => { + const mockFetch = globalThis.fetch as unknown as ReturnType; + mockFetch.mockResolvedValueOnce({ ok: false, status: 403, text: async () => 'Forbidden' } as never); + + const result = await sendNtfy(ntfyUrl, null, payload); + expect(result).toBe(false); + expect(vi.mocked(logError)).toHaveBeenCalledWith(expect.stringContaining('403')); + }); + + it('NTFY-007 — network error returns false', async () => { + const mockFetch = globalThis.fetch as unknown as ReturnType; + mockFetch.mockRejectedValueOnce(new Error('Network failure')); + + const result = await sendNtfy(ntfyUrl, null, payload); + expect(result).toBe(false); + expect(vi.mocked(logError)).toHaveBeenCalledWith(expect.stringContaining('Network failure')); + }); + + it('NTFY-008 — unknown event falls back to priority 3 and no Tags header', async () => { + const mockFetch = globalThis.fetch as unknown as ReturnType; + mockFetch.mockResolvedValueOnce({ ok: true, text: async () => '' } as never); + + await sendNtfy(ntfyUrl, null, { event: 'unknown_event', title: 'T', body: 'B' }); + + const [, calledOpts] = mockFetch.mock.calls[0]; + expect(calledOpts.headers['Priority']).toBe('3'); + expect(calledOpts.headers['Tags']).toBeUndefined(); // empty tags = no header + }); +});