refactor(admin): share the notification settings saves and tests between desktop and phone

useAdminNotificationSettings now holds the SMTP save and test, the trip reminder switch and the admin webhook and ntfy save, test and token clear. The desktop tab drops its inline handlers and both views keep their markup.
This commit is contained in:
Maurice
2026-10-09 10:31:15 +02:00
parent ab3a69e18c
commit 163b4c54e0
4 changed files with 378 additions and 218 deletions
@@ -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<string, string>) {
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');
});
});
@@ -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<string, string>;
setSmtpValues: Dispatch<SetStateAction<Record<string, string>>>;
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<string, string>): Record<string, string> {
const payload: Record<string, string> = {};
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,
};
}
@@ -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<string, string> = {}
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<string, string> = {}
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 (
<div className="space-y-3">
@@ -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<string, LucideIcon> = { 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 <div className="flex flex-wrap items-center gap-2 border-t border-edge-faint pt-4" style={fs(13, 'body')}>{children}</div>
@@ -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<string, string> = {}
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]) => (
<EditorField key={field.key} label={field.label} htmlFor={`${ids}-${field.key}`}>
@@ -153,17 +138,7 @@ export default function AdminNotificationsTab({ admin, t }: AdminNotificationsTa
<Save size={14} strokeWidth={2.2} />{t('common.save')}
</button>
<button type="button"
onClick={async () => {
const smtpKeys = ['smtp_host', 'smtp_port', 'smtp_user', 'smtp_pass', 'smtp_from', 'smtp_skip_tls_verify']
const payload: Record<string, string> = {}
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')) }
}}
onClick={testSmtp}
disabled={!smtpConfigured}
className={SETTINGS_BUTTON}
>
@@ -202,20 +177,7 @@ export default function AdminNotificationsTab({ admin, t }: AdminNotificationsTa
<ToggleSwitch
on={tripRemindersActive}
label={t('admin.notifications.tripReminders.title')}
onToggle={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'))
}
}}
onToggle={toggleTripReminders}
/>
}
/>
@@ -241,27 +203,12 @@ export default function AdminNotificationsTab({ admin, t }: AdminNotificationsTa
)}
<CardActions>
<button type="button"
onClick={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')) }
}}
onClick={saveAdminWebhook}
className={SETTINGS_BUTTON_PRIMARY}>
<Save size={14} strokeWidth={2.2} />{t('common.save')}
</button>
<button type="button"
onClick={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(() => {})
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')) }
}}
onClick={testAdminWebhook}
disabled={!smtpValues.admin_webhook_url?.trim()}
className={SETTINGS_BUTTON}
>
@@ -306,13 +253,7 @@ export default function AdminNotificationsTab({ admin, t }: AdminNotificationsTa
/>
{smtpValues.admin_ntfy_token === MASK && (
<button type="button"
onClick={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')) }
}}
onClick={clearNtfyToken}
className={`${SETTINGS_BUTTON_DANGER} flex-none`}
style={fs(13, 'body')}
>
@@ -324,37 +265,12 @@ export default function AdminNotificationsTab({ admin, t }: AdminNotificationsTa
</>)}
<CardActions>
<button type="button"
onClick={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')) }
}}
onClick={saveAdminNtfy}
className={SETTINGS_BUTTON_PRIMARY}>
<Save size={14} strokeWidth={2.2} />{t('common.save')}
</button>
<button type="button"
onClick={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')) }
}}
onClick={testAdminNtfy}
disabled={!smtpValues.admin_ntfy_topic?.trim()}
className={SETTINGS_BUTTON}
>