diff --git a/client/src/components/Admin/useAdminNotificationSettings.test.ts b/client/src/components/Admin/useAdminNotificationSettings.test.ts new file mode 100644 index 000000000..7762505a9 --- /dev/null +++ b/client/src/components/Admin/useAdminNotificationSettings.test.ts @@ -0,0 +1,192 @@ +import { act, renderHook, waitFor } from '@testing-library/react'; +import { useState } from 'react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { MASK, useAdminNotificationSettings } from './useAdminNotificationSettings'; + +// FE-HOOK-ADMNOTIF-001 to FE-HOOK-ADMNOTIF-013 + +const api = vi.hoisted(() => ({ + updateAppSettings: vi.fn(), + getAppConfig: vi.fn(), + testSmtp: vi.fn(), + testWebhook: vi.fn(), + testNtfy: vi.fn(), +})); +vi.mock('../../api/client', () => ({ + authApi: { updateAppSettings: api.updateAppSettings, getAppConfig: api.getAppConfig }, + notificationsApi: { testSmtp: api.testSmtp, testWebhook: api.testWebhook, testNtfy: api.testNtfy }, +})); + +const t = (key: string) => key; + +function mount(initial: Record) { + const toast = { success: vi.fn(), error: vi.fn() }; + const setTripRemindersEnabled = vi.fn(); + const hook = renderHook(() => { + const [smtpValues, setSmtpValues] = useState(initial); + return { + smtpValues, + settings: useAdminNotificationSettings({ smtpValues, setSmtpValues, setTripRemindersEnabled, toast }, t), + }; + }); + return { ...hook, toast, setTripRemindersEnabled }; +} + +beforeEach(() => { + for (const fn of Object.values(api)) fn.mockReset(); + api.updateAppSettings.mockResolvedValue({}); + api.getAppConfig.mockResolvedValue({ trip_reminders_enabled: true }); + api.testSmtp.mockResolvedValue({ success: true }); + api.testWebhook.mockResolvedValue({ success: true }); + api.testNtfy.mockResolvedValue({ success: true }); +}); + +describe('useAdminNotificationSettings', () => { + it('FE-HOOK-ADMNOTIF-001: derives the reminder switch and whether SMTP is configured', () => { + expect(mount({}).result.current.settings.tripRemindersActive).toBe(true); + expect(mount({ notify_trip_reminder: 'false' }).result.current.settings.tripRemindersActive).toBe(false); + expect(mount({ smtp_host: ' ' }).result.current.settings.smtpConfigured).toBe(false); + expect(mount({ smtp_host: 'mail.example.com' }).result.current.settings.smtpConfigured).toBe(true); + }); + + it('FE-HOOK-ADMNOTIF-002: saving SMTP sends only the loaded credential keys and refreshes the reminders', async () => { + const { result, toast, setTripRemindersEnabled } = mount({ + smtp_host: 'h', + smtp_pass: MASK, + admin_webhook_url: 'x', + }); + await act(() => result.current.settings.saveSmtp()); + expect(api.updateAppSettings).toHaveBeenCalledWith({ smtp_host: 'h', smtp_pass: MASK }); + expect(toast.success).toHaveBeenCalledWith('admin.notifications.saved'); + await waitFor(() => expect(setTripRemindersEnabled).toHaveBeenCalledWith(true)); + }); + + it('FE-HOOK-ADMNOTIF-003: a failed SMTP save toasts the error; a config without the flag changes nothing', async () => { + api.getAppConfig.mockResolvedValue({}); + const { result, toast, setTripRemindersEnabled } = mount({ smtp_host: 'h' }); + await act(() => result.current.settings.saveSmtp()); + await waitFor(() => expect(api.getAppConfig).toHaveBeenCalled()); + expect(setTripRemindersEnabled).not.toHaveBeenCalled(); + api.updateAppSettings.mockRejectedValue(new Error('x')); + await act(() => result.current.settings.saveSmtp()); + expect(toast.error).toHaveBeenCalledWith('common.error'); + }); + + it('FE-HOOK-ADMNOTIF-004: the SMTP test saves first, even when that fails, then reports the result', async () => { + api.updateAppSettings.mockRejectedValue(new Error('x')); + api.testSmtp.mockResolvedValue({ success: false, error: 'Auth failed' }); + const { result, toast } = mount({ smtp_host: 'h' }); + await act(() => result.current.settings.testSmtp()); + expect(api.updateAppSettings).toHaveBeenCalledWith({ smtp_host: 'h' }); + expect(toast.error).toHaveBeenCalledWith('Auth failed'); + api.testSmtp.mockResolvedValue({ success: false }); + await act(() => result.current.settings.testSmtp()); + expect(toast.error).toHaveBeenLastCalledWith('admin.smtp.testFailed'); + api.testSmtp.mockResolvedValue({ success: true }); + await act(() => result.current.settings.testSmtp()); + expect(toast.success).toHaveBeenCalledWith('admin.smtp.testSuccess'); + api.testSmtp.mockRejectedValue(new Error('x')); + await act(() => result.current.settings.testSmtp()); + expect(toast.error).toHaveBeenLastCalledWith('admin.smtp.testFailed'); + }); + + it('FE-HOOK-ADMNOTIF-005: toggling reminders off saves, toasts and refreshes the flag', async () => { + const { result, toast, setTripRemindersEnabled } = mount({}); + await act(() => result.current.settings.toggleTripReminders()); + expect(result.current.smtpValues.notify_trip_reminder).toBe('false'); + expect(api.updateAppSettings).toHaveBeenCalledWith({ notify_trip_reminder: 'false' }); + expect(toast.success).toHaveBeenCalledWith('admin.notifications.tripReminders.disabled'); + await waitFor(() => expect(setTripRemindersEnabled).toHaveBeenCalledWith(true)); + }); + + it('FE-HOOK-ADMNOTIF-006: a failed reminder toggle puts the old value back and toasts', async () => { + api.updateAppSettings.mockRejectedValue(new Error('x')); + const { result, toast } = mount({ notify_trip_reminder: 'false' }); + await act(() => result.current.settings.toggleTripReminders()); + expect(api.updateAppSettings).toHaveBeenCalledWith({ notify_trip_reminder: 'true' }); + expect(result.current.smtpValues.notify_trip_reminder).toBe('false'); + expect(toast.error).toHaveBeenCalledWith('common.error'); + }); + + it('FE-HOOK-ADMNOTIF-007: saving the admin webhook sends the url or an empty string', async () => { + const { result, toast } = mount({}); + await act(() => result.current.settings.saveAdminWebhook()); + expect(api.updateAppSettings).toHaveBeenCalledWith({ admin_webhook_url: '' }); + expect(toast.success).toHaveBeenCalledWith('admin.notifications.adminWebhookPanel.saved'); + api.updateAppSettings.mockRejectedValue(new Error('x')); + await act(() => result.current.settings.saveAdminWebhook()); + expect(toast.error).toHaveBeenCalledWith('common.error'); + }); + + it('FE-HOOK-ADMNOTIF-008: testing a typed webhook saves it first and passes it on', async () => { + const { result, toast } = mount({ admin_webhook_url: 'https://hook' }); + await act(() => result.current.settings.testAdminWebhook()); + expect(api.updateAppSettings).toHaveBeenCalledWith({ admin_webhook_url: 'https://hook' }); + expect(api.testWebhook).toHaveBeenCalledWith('https://hook'); + expect(toast.success).toHaveBeenCalledWith('admin.notifications.adminWebhookPanel.testSuccess'); + }); + + it('FE-HOOK-ADMNOTIF-009: a masked webhook is tested as stored, without a save', async () => { + api.testWebhook.mockResolvedValue({ success: false, error: '404' }); + const { result, toast } = mount({ admin_webhook_url: MASK }); + await act(() => result.current.settings.testAdminWebhook()); + expect(api.updateAppSettings).not.toHaveBeenCalled(); + expect(api.testWebhook).toHaveBeenCalledWith(undefined); + expect(toast.error).toHaveBeenCalledWith('404'); + api.testWebhook.mockRejectedValue(new Error('x')); + await act(() => result.current.settings.testAdminWebhook()); + expect(toast.error).toHaveBeenLastCalledWith('admin.notifications.adminWebhookPanel.testFailed'); + }); + + it('FE-HOOK-ADMNOTIF-010: clearing the ntfy token saves an empty token and empties the field', async () => { + const { result, toast } = mount({ admin_ntfy_token: MASK }); + await act(() => result.current.settings.clearNtfyToken()); + expect(api.updateAppSettings).toHaveBeenCalledWith({ admin_ntfy_token: '' }); + expect(result.current.smtpValues.admin_ntfy_token).toBe(''); + expect(toast.success).toHaveBeenCalledWith('admin.notifications.adminNtfyPanel.tokenCleared'); + }); + + it('FE-HOOK-ADMNOTIF-011: a failed token clear keeps the mask and toasts', async () => { + api.updateAppSettings.mockRejectedValue(new Error('x')); + const { result, toast } = mount({ admin_ntfy_token: MASK }); + await act(() => result.current.settings.clearNtfyToken()); + expect(result.current.smtpValues.admin_ntfy_token).toBe(MASK); + expect(toast.error).toHaveBeenCalledWith('common.error'); + }); + + it('FE-HOOK-ADMNOTIF-012: saving ntfy sends a typed token but never the mask', async () => { + const typed = mount({ admin_ntfy_server: 'https://ntfy', admin_ntfy_topic: 'trek', admin_ntfy_token: 'tk' }); + await act(() => typed.result.current.settings.saveAdminNtfy()); + expect(api.updateAppSettings).toHaveBeenLastCalledWith({ + admin_ntfy_server: 'https://ntfy', + admin_ntfy_topic: 'trek', + admin_ntfy_token: 'tk', + }); + expect(typed.toast.success).toHaveBeenCalledWith('admin.notifications.adminNtfyPanel.saved'); + const masked = mount({ admin_ntfy_token: MASK }); + await act(() => masked.result.current.settings.saveAdminNtfy()); + expect(api.updateAppSettings).toHaveBeenLastCalledWith({ admin_ntfy_server: '', admin_ntfy_topic: '' }); + api.updateAppSettings.mockRejectedValue(new Error('x')); + await act(() => masked.result.current.settings.saveAdminNtfy()); + expect(masked.toast.error).toHaveBeenCalledWith('common.error'); + }); + + it('FE-HOOK-ADMNOTIF-013: the ntfy test needs a topic and sends the token only when typed', async () => { + const empty = mount({ admin_ntfy_topic: ' ' }); + await act(() => empty.result.current.settings.testAdminNtfy()); + expect(api.testNtfy).not.toHaveBeenCalled(); + const masked = mount({ admin_ntfy_topic: ' trek ', admin_ntfy_token: MASK }); + await act(() => masked.result.current.settings.testAdminNtfy()); + expect(api.testNtfy).toHaveBeenLastCalledWith({ topic: 'trek', server: null, token: null }); + expect(masked.toast.success).toHaveBeenCalledWith('admin.notifications.adminNtfyPanel.testSuccess'); + api.testNtfy.mockResolvedValue({ success: false }); + const typed = mount({ admin_ntfy_topic: 'trek', admin_ntfy_server: 'https://n', admin_ntfy_token: 'tk' }); + await act(() => typed.result.current.settings.testAdminNtfy()); + expect(api.testNtfy).toHaveBeenLastCalledWith({ topic: 'trek', server: 'https://n', token: 'tk' }); + expect(typed.toast.error).toHaveBeenCalledWith('admin.notifications.adminNtfyPanel.testFailed'); + api.testNtfy.mockRejectedValue(new Error('x')); + await act(() => typed.result.current.settings.testAdminNtfy()); + expect(typed.toast.error).toHaveBeenLastCalledWith('admin.notifications.adminNtfyPanel.testFailed'); + }); +}); diff --git a/client/src/components/Admin/useAdminNotificationSettings.ts b/client/src/components/Admin/useAdminNotificationSettings.ts new file mode 100644 index 000000000..ed9e74121 --- /dev/null +++ b/client/src/components/Admin/useAdminNotificationSettings.ts @@ -0,0 +1,167 @@ +import type { Dispatch, SetStateAction } from 'react'; + +import { authApi, notificationsApi } from '../../api/client'; +import type { TranslationFn } from '../../types'; + +/** A stored secret comes back from the server as this mask. */ +export const MASK = '••••••••'; + +const SMTP_KEYS = ['smtp_host', 'smtp_port', 'smtp_user', 'smtp_pass', 'smtp_from', 'smtp_skip_tls_verify']; + +interface NotificationSettingsHost { + smtpValues: Record; + setSmtpValues: Dispatch>>; + setTripRemindersEnabled: (enabled: boolean) => void; + toast: { success: (message: string) => void; error: (message: string) => void }; +} + +/** The SMTP credentials as they stand, without the keys that were never loaded. */ +function smtpPayload(values: Record): Record { + const payload: Record = {}; + for (const k of SMTP_KEYS) { + if (values[k] !== undefined) payload[k] = values[k]; + } + return payload; +} + +/** + * The saves and tests behind the admin notification settings, shared by the desktop + * Notifications tab and its phone twin: SMTP credentials, the trip reminder switch, and + * the admin webhook and ntfy targets. The values live in the admin hook; this only reads + * and writes them. The channel switches are useNotificationChannels. + */ +export function useAdminNotificationSettings( + { smtpValues, setSmtpValues, setTripRemindersEnabled, toast }: NotificationSettingsHost, + t: TranslationFn +) { + const tripRemindersActive = smtpValues.notify_trip_reminder !== 'false'; + const smtpConfigured = !!smtpValues.smtp_host?.trim(); + + const refreshTripReminders = () => { + authApi + .getAppConfig() + .then((c: { trip_reminders_enabled?: boolean }) => { + if (c?.trip_reminders_enabled !== undefined) setTripRemindersEnabled(c.trip_reminders_enabled); + }) + .catch(() => { + /* the switch keeps what it shows */ + }); + }; + + const saveSmtp = async () => { + // Saves credentials only: channel activation is auto-saved by the toggle. + try { + await authApi.updateAppSettings(smtpPayload(smtpValues)); + toast.success(t('admin.notifications.saved')); + refreshTripReminders(); + } catch { + toast.error(t('common.error')); + } + }; + + const testSmtp = async () => { + await authApi.updateAppSettings(smtpPayload(smtpValues)).catch(() => { + /* the test below reports what the server has */ + }); + try { + const result = await notificationsApi.testSmtp(); + if (result.success) toast.success(t('admin.smtp.testSuccess')); + else toast.error(result.error || t('admin.smtp.testFailed')); + } catch { + toast.error(t('admin.smtp.testFailed')); + } + }; + + const toggleTripReminders = async () => { + const next = !tripRemindersActive; + setSmtpValues((prev) => ({ ...prev, notify_trip_reminder: next ? 'true' : 'false' })); + try { + await authApi.updateAppSettings({ notify_trip_reminder: next ? 'true' : 'false' }); + toast.success( + next ? t('admin.notifications.tripReminders.enabled') : t('admin.notifications.tripReminders.disabled') + ); + refreshTripReminders(); + } catch { + setSmtpValues((prev) => ({ ...prev, notify_trip_reminder: tripRemindersActive ? 'true' : 'false' })); + toast.error(t('common.error')); + } + }; + + const saveAdminWebhook = async () => { + try { + await authApi.updateAppSettings({ admin_webhook_url: smtpValues.admin_webhook_url || '' }); + toast.success(t('admin.notifications.adminWebhookPanel.saved')); + } catch { + toast.error(t('common.error')); + } + }; + + const testAdminWebhook = async () => { + // A masked value means the URL only lives on the server: send no url and let the + // server test the stored one instead of pre-saving the mask. + const url = smtpValues.admin_webhook_url === MASK ? undefined : smtpValues.admin_webhook_url; + try { + if (url) + await authApi.updateAppSettings({ admin_webhook_url: url }).catch(() => { + /* the test below reports what the server has */ + }); + const result = await notificationsApi.testWebhook(url); + if (result.success) toast.success(t('admin.notifications.adminWebhookPanel.testSuccess')); + else toast.error(result.error || t('admin.notifications.adminWebhookPanel.testFailed')); + } catch { + toast.error(t('admin.notifications.adminWebhookPanel.testFailed')); + } + }; + + const clearNtfyToken = async () => { + try { + await authApi.updateAppSettings({ admin_ntfy_token: '' }); + setSmtpValues((prev) => ({ ...prev, admin_ntfy_token: '' })); + toast.success(t('admin.notifications.adminNtfyPanel.tokenCleared')); + } catch { + toast.error(t('common.error')); + } + }; + + const saveAdminNtfy = async () => { + try { + await authApi.updateAppSettings({ + admin_ntfy_server: smtpValues.admin_ntfy_server || '', + admin_ntfy_topic: smtpValues.admin_ntfy_topic || '', + ...(smtpValues.admin_ntfy_token && smtpValues.admin_ntfy_token !== MASK + ? { admin_ntfy_token: smtpValues.admin_ntfy_token } + : {}), + }); + toast.success(t('admin.notifications.adminNtfyPanel.saved')); + } catch { + toast.error(t('common.error')); + } + }; + + const testAdminNtfy = async () => { + const topic = smtpValues.admin_ntfy_topic?.trim(); + if (!topic) return; + try { + const token = + smtpValues.admin_ntfy_token && smtpValues.admin_ntfy_token !== MASK ? smtpValues.admin_ntfy_token : null; + const result = await notificationsApi.testNtfy({ topic, server: smtpValues.admin_ntfy_server || null, token }); + if (result.success) toast.success(t('admin.notifications.adminNtfyPanel.testSuccess')); + else toast.error(result.error || t('admin.notifications.adminNtfyPanel.testFailed')); + } catch { + toast.error(t('admin.notifications.adminNtfyPanel.testFailed')); + } + }; + + return { + tripRemindersActive, + smtpConfigured, + saveSmtp, + testSmtp, + toggleTripReminders, + saveAdminWebhook, + testAdminWebhook, + clearNtfyToken, + saveAdminNtfy, + testAdminNtfy, + }; +} diff --git a/client/src/mobile/screens/admin/MAdminNotificationsSection.tsx b/client/src/mobile/screens/admin/MAdminNotificationsSection.tsx index 01bde58e8..7373e58e7 100644 --- a/client/src/mobile/screens/admin/MAdminNotificationsSection.tsx +++ b/client/src/mobile/screens/admin/MAdminNotificationsSection.tsx @@ -1,7 +1,7 @@ -import { authApi, notificationsApi } from '../../../api/client' import type { TranslationFn } from '../../../types' import type { useAdmin } from '../../../pages/admin/useAdmin' import { SWITCH_ONLY_CHANNELS, useNotificationChannels } from '../../../components/Admin/useNotificationChannels' +import { useAdminNotificationSettings } from '../../../components/Admin/useAdminNotificationSettings' import MToggle from '../../components/MToggle' import MAdminNotifyMatrix from './MAdminNotifyMatrix' import { MAdminButton, MAdminCard, MAdminCardHead, MAdminField, MAdminInput, MAdminRow } from './MAdminUi' @@ -24,129 +24,14 @@ const SMTP_FIELDS = [ // per-event preference matrix: the desktop notifications tab in mobile cards. // The channel switches come from useNotificationChannels, shared with desktop. export default function MAdminNotificationsSection({ admin, t }: MAdminNotificationsSectionProps) { - const { toast, smtpValues, setSmtpValues, smtpLoaded, setTripRemindersEnabled } = admin + const { toast, smtpValues, setSmtpValues, smtpLoaded } = admin const channels = useNotificationChannels(admin, t) const emailActive = channels.isActive('email') - const tripRemindersActive = smtpValues.notify_trip_reminder !== 'false' - const smtpConfigured = !!smtpValues.smtp_host?.trim() - - const saveSmtp = async () => { - // Saves credentials only — channel activation is auto-saved by the toggle. - const notifKeys = ['smtp_host', 'smtp_port', 'smtp_user', 'smtp_pass', 'smtp_from', 'smtp_skip_tls_verify'] - const payload: Record = {} - for (const k of notifKeys) { - if (smtpValues[k] !== undefined) payload[k] = smtpValues[k] - } - try { - await authApi.updateAppSettings(payload) - toast.success(t('admin.notifications.saved')) - authApi - .getAppConfig() - .then((c: { trip_reminders_enabled?: boolean }) => { - if (c?.trip_reminders_enabled !== undefined) setTripRemindersEnabled(c.trip_reminders_enabled) - }) - .catch(() => {}) - } catch { - toast.error(t('common.error')) - } - } - - const testSmtp = async () => { - const smtpKeys = ['smtp_host', 'smtp_port', 'smtp_user', 'smtp_pass', 'smtp_from', 'smtp_skip_tls_verify'] - const payload: Record = {} - for (const k of smtpKeys) { - if (smtpValues[k] !== undefined) payload[k] = smtpValues[k] - } - await authApi.updateAppSettings(payload).catch(() => {}) - try { - const result = await notificationsApi.testSmtp() - if (result.success) toast.success(t('admin.smtp.testSuccess')) - else toast.error(result.error || t('admin.smtp.testFailed')) - } catch { - toast.error(t('admin.smtp.testFailed')) - } - } - - const toggleTripReminders = async () => { - const next = !tripRemindersActive - setSmtpValues((prev) => ({ ...prev, notify_trip_reminder: next ? 'true' : 'false' })) - try { - await authApi.updateAppSettings({ notify_trip_reminder: next ? 'true' : 'false' }) - toast.success( - next ? t('admin.notifications.tripReminders.enabled') : t('admin.notifications.tripReminders.disabled'), - ) - authApi - .getAppConfig() - .then((c: { trip_reminders_enabled?: boolean }) => { - if (c?.trip_reminders_enabled !== undefined) setTripRemindersEnabled(c.trip_reminders_enabled) - }) - .catch(() => {}) - } catch { - setSmtpValues((prev) => ({ ...prev, notify_trip_reminder: tripRemindersActive ? 'true' : 'false' })) - toast.error(t('common.error')) - } - } - - const saveAdminWebhook = async () => { - try { - await authApi.updateAppSettings({ admin_webhook_url: smtpValues.admin_webhook_url || '' }) - toast.success(t('admin.notifications.adminWebhookPanel.saved')) - } catch { - toast.error(t('common.error')) - } - } - - const testAdminWebhook = async () => { - const url = smtpValues.admin_webhook_url === '••••••••' ? undefined : smtpValues.admin_webhook_url - try { - if (url) await authApi.updateAppSettings({ admin_webhook_url: url }).catch(() => {}) - const result = await notificationsApi.testWebhook(url) - if (result.success) toast.success(t('admin.notifications.adminWebhookPanel.testSuccess')) - else toast.error(result.error || t('admin.notifications.adminWebhookPanel.testFailed')) - } catch { - toast.error(t('admin.notifications.adminWebhookPanel.testFailed')) - } - } - - const saveAdminNtfy = async () => { - try { - await authApi.updateAppSettings({ - admin_ntfy_server: smtpValues.admin_ntfy_server || '', - admin_ntfy_topic: smtpValues.admin_ntfy_topic || '', - ...(smtpValues.admin_ntfy_token && smtpValues.admin_ntfy_token !== '••••••••' - ? { admin_ntfy_token: smtpValues.admin_ntfy_token } - : {}), - }) - toast.success(t('admin.notifications.adminNtfyPanel.saved')) - } catch { - toast.error(t('common.error')) - } - } - - const testAdminNtfy = async () => { - const topic = smtpValues.admin_ntfy_topic?.trim() - if (!topic) return - try { - const token = - smtpValues.admin_ntfy_token && smtpValues.admin_ntfy_token !== '••••••••' ? smtpValues.admin_ntfy_token : null - const result = await notificationsApi.testNtfy({ topic, server: smtpValues.admin_ntfy_server || null, token }) - if (result.success) toast.success(t('admin.notifications.adminNtfyPanel.testSuccess')) - else toast.error(result.error || t('admin.notifications.adminNtfyPanel.testFailed')) - } catch { - toast.error(t('admin.notifications.adminNtfyPanel.testFailed')) - } - } - - const clearNtfyToken = async () => { - try { - await authApi.updateAppSettings({ admin_ntfy_token: '' }) - setSmtpValues((prev) => ({ ...prev, admin_ntfy_token: '' })) - toast.success(t('admin.notifications.adminNtfyPanel.tokenCleared')) - } catch { - toast.error(t('common.error')) - } - } + const { + tripRemindersActive, smtpConfigured, saveSmtp, testSmtp, toggleTripReminders, saveAdminWebhook, testAdminWebhook, + clearNtfyToken, saveAdminNtfy, testAdminNtfy, + } = useAdminNotificationSettings(admin, t) return (
diff --git a/client/src/pages/admin/AdminNotificationsTab.tsx b/client/src/pages/admin/AdminNotificationsTab.tsx index e2a4d2a62..94d00c6a3 100644 --- a/client/src/pages/admin/AdminNotificationsTab.tsx +++ b/client/src/pages/admin/AdminNotificationsTab.tsx @@ -1,5 +1,4 @@ import React, { useId } from 'react' -import { authApi, notificationsApi } from '../../api/client' import { Bell, BellRing, CalendarClock, Mail, Save, Send, Smartphone, Webhook } from 'lucide-react' import type { LucideIcon } from 'lucide-react' import type { TranslationFn } from '../../types' @@ -7,6 +6,7 @@ import type { useAdmin } from './useAdmin' import AdminNotificationsPanel from './AdminNotificationsPanel' import AdminNotificationDefaultsPanel from './AdminNotificationDefaultsPanel' import { SWITCH_ONLY_CHANNELS, useNotificationChannels } from '../../components/Admin/useNotificationChannels' +import { MASK, useAdminNotificationSettings } from '../../components/Admin/useAdminNotificationSettings' import ToggleSwitch from '../../components/Settings/ToggleSwitch' import { SETTINGS_BUTTON, SETTINGS_BUTTON_DANGER, SETTINGS_BUTTON_PRIMARY, SettingRow, SettingRows, SettingsCard, @@ -22,9 +22,6 @@ interface AdminNotificationsTabProps { /** The glyph on each switch-only channel's card; anything else is the push channel. */ const CHANNEL_ICONS: Record = { webhook: Webhook, ntfy: BellRing } -/** A stored secret comes back from the server as this mask. */ -const MASK = '••••••••' - /** The save and test buttons under a card's fields, on a hairline of their own. */ function CardActions({ children }: { children: React.ReactNode }): React.ReactElement { return
{children}
@@ -55,27 +52,15 @@ const SMTP_FIELDS: { key: string; label: string; placeholder: string; type?: str // preference matrix. The channel switches come from useNotificationChannels, // which the phone section shares. export default function AdminNotificationsTab({ admin, t }: AdminNotificationsTabProps): React.ReactElement { - const { toast, smtpValues, setSmtpValues, smtpLoaded, setTripRemindersEnabled, managed } = admin + const { toast, smtpValues, setSmtpValues, smtpLoaded, managed } = admin const ids = useId() const channels = useNotificationChannels(admin, t) const emailActive = channels.isActive('email') - const tripRemindersActive = smtpValues.notify_trip_reminder !== 'false' - - const smtpConfigured = !!(smtpValues.smtp_host?.trim()) - const saveNotifications = async () => { - // Saves credentials only — channel activation is auto-saved by the toggle - const notifKeys = ['smtp_host', 'smtp_port', 'smtp_user', 'smtp_pass', 'smtp_from', 'smtp_skip_tls_verify'] - const payload: Record = {} - for (const k of notifKeys) { if (smtpValues[k] !== undefined) payload[k] = smtpValues[k] } - try { - await authApi.updateAppSettings(payload) - toast.success(t('admin.notifications.saved')) - authApi.getAppConfig().then((c: { trip_reminders_enabled?: boolean }) => { - if (c?.trip_reminders_enabled !== undefined) setTripRemindersEnabled(c.trip_reminders_enabled) - }).catch(() => {}) - } catch { toast.error(t('common.error')) } - } + const { + tripRemindersActive, smtpConfigured, saveSmtp: saveNotifications, testSmtp, toggleTripReminders, saveAdminWebhook, + testAdminWebhook, clearNtfyToken, saveAdminNtfy, testAdminNtfy, + } = useAdminNotificationSettings(admin, t) const smtpField = (field: typeof SMTP_FIELDS[number]) => ( @@ -153,17 +138,7 @@ export default function AdminNotificationsTab({ admin, t }: AdminNotificationsTa {t('common.save')}