mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-19 13:21:46 +00:00
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9e8d101d63 | |||
| 5656731850 | |||
| 7c4ac70db3 | |||
| bfe84b3016 |
@@ -486,6 +486,7 @@ export const notificationsApi = {
|
||||
updatePreferences: (prefs: Record<string, Record<string, boolean>>) => 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 = {
|
||||
|
||||
@@ -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(<NotificationsTab />);
|
||||
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(<NotificationsTab />);
|
||||
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(
|
||||
<>
|
||||
<NotificationsTab />
|
||||
<ToastContainer />
|
||||
</>,
|
||||
);
|
||||
|
||||
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(
|
||||
|
||||
@@ -8,15 +8,17 @@ import Section from './Section'
|
||||
|
||||
interface PreferencesMatrix {
|
||||
preferences: Record<string, Record<string, boolean>>
|
||||
available_channels: { email: boolean; webhook: boolean; inapp: boolean }
|
||||
available_channels: { email: boolean; webhook: boolean; inapp: boolean; ntfy: boolean }
|
||||
event_types: string[]
|
||||
implemented_combos: Record<string, string[]>
|
||||
defaults?: { ntfyServer: string | null }
|
||||
}
|
||||
|
||||
const CHANNEL_LABEL_KEYS: Record<string, string> = {
|
||||
email: 'settings.notificationPreferences.email',
|
||||
webhook: 'settings.notificationPreferences.webhook',
|
||||
inapp: 'settings.notificationPreferences.inapp',
|
||||
ntfy: 'settings.notificationPreferences.ntfy',
|
||||
}
|
||||
|
||||
const EVENT_LABEL_KEYS: Record<string, string> = {
|
||||
@@ -39,6 +41,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 +58,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 +123,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 <p style={{ fontSize: 12, color: 'var(--text-faint)', fontStyle: 'italic' }}>{t('common.loading')}</p>
|
||||
|
||||
@@ -139,7 +202,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')}
|
||||
</button>
|
||||
<button
|
||||
onClick={testWebhookUrl}
|
||||
@@ -151,6 +214,66 @@ export default function NotificationsTab(): React.ReactElement {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{matrix.available_channels.ntfy && (
|
||||
<div style={{ marginBottom: 16, padding: '12px', background: 'var(--bg-secondary)', borderRadius: 8, border: '1px solid var(--border-primary)' }}>
|
||||
<label style={{ display: 'block', fontSize: 12, fontWeight: 600, color: 'var(--text-secondary)', marginBottom: 4 }}>
|
||||
{t('settings.ntfyUrl.topicLabel')}
|
||||
</label>
|
||||
<p style={{ fontSize: 11, color: 'var(--text-faint)', marginBottom: 8 }}>{t('settings.ntfyUrl.hint')}</p>
|
||||
<input
|
||||
type="text"
|
||||
value={ntfyTopic}
|
||||
onChange={e => setNtfyTopic(e.target.value)}
|
||||
placeholder={t('settings.ntfyUrl.topicPlaceholder')}
|
||||
style={{ width: '100%', boxSizing: 'border-box', fontSize: 13, padding: '6px 10px', border: '1px solid var(--border-primary)', borderRadius: 6, background: 'var(--bg-primary)', color: 'var(--text-primary)', marginBottom: 6 }}
|
||||
/>
|
||||
<label style={{ display: 'block', fontSize: 12, fontWeight: 600, color: 'var(--text-secondary)', marginBottom: 4 }}>
|
||||
{t('settings.ntfyUrl.serverLabel')}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={ntfyServer}
|
||||
onChange={e => setNtfyServer(e.target.value)}
|
||||
placeholder={matrix.defaults?.ntfyServer || t('settings.ntfyUrl.serverPlaceholder')}
|
||||
style={{ width: '100%', boxSizing: 'border-box', fontSize: 13, padding: '6px 10px', border: '1px solid var(--border-primary)', borderRadius: 6, background: 'var(--bg-primary)', color: 'var(--text-primary)', marginBottom: 6 }}
|
||||
/>
|
||||
<label style={{ display: 'block', fontSize: 12, fontWeight: 600, color: 'var(--text-secondary)', marginBottom: 4 }}>
|
||||
{t('settings.ntfyUrl.tokenLabel')}
|
||||
</label>
|
||||
<p style={{ fontSize: 11, color: 'var(--text-faint)', marginBottom: 4 }}>{t('settings.ntfyUrl.tokenHint')}</p>
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
||||
<input
|
||||
type="password"
|
||||
value={ntfyToken}
|
||||
onChange={e => setNtfyToken(e.target.value)}
|
||||
placeholder={ntfyTokenIsSet ? '••••••••' : ''}
|
||||
style={{ flex: 1, fontSize: 13, padding: '6px 10px', border: '1px solid var(--border-primary)', borderRadius: 6, background: 'var(--bg-primary)', color: 'var(--text-primary)' }}
|
||||
/>
|
||||
{ntfyTokenIsSet && (
|
||||
<button
|
||||
onClick={clearNtfyToken}
|
||||
style={{ fontSize: 12, padding: '6px 12px', background: 'transparent', color: 'var(--color-danger, #e53e3e)', border: '1px solid var(--color-danger, #e53e3e)', borderRadius: 6, cursor: 'pointer' }}
|
||||
>
|
||||
{t('common.clear')}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={saveNtfySettings}
|
||||
disabled={ntfySaving}
|
||||
style={{ fontSize: 12, padding: '6px 12px', background: 'var(--text-primary)', color: 'var(--bg-primary)', border: 'none', borderRadius: 6, cursor: ntfySaving ? 'not-allowed' : 'pointer', opacity: ntfySaving ? 0.6 : 1 }}
|
||||
>
|
||||
{t('common.save')}
|
||||
</button>
|
||||
<button
|
||||
onClick={testNtfySettings}
|
||||
disabled={!ntfyTopic || ntfyTesting}
|
||||
style={{ fontSize: 12, padding: '6px 12px', background: 'transparent', color: 'var(--text-secondary)', border: '1px solid var(--border-primary)', borderRadius: 6, cursor: (!ntfyTopic || ntfyTesting) ? 'not-allowed' : 'pointer', opacity: (!ntfyTopic || ntfyTesting) ? 0.5 : 1 }}
|
||||
>
|
||||
{t('settings.ntfyUrl.test')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* Header row */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: `1fr ${visibleChannels.map(() => '64px').join(' ')}`, gap: 4, paddingBottom: 6, marginBottom: 4, borderBottom: '1px solid var(--border-primary)' }}>
|
||||
<span />
|
||||
|
||||
@@ -8,6 +8,7 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
|
||||
'common.showMore': 'عرض المزيد',
|
||||
'common.showLess': 'عرض أقل',
|
||||
'common.cancel': 'إلغاء',
|
||||
'common.clear': 'مسح',
|
||||
'common.delete': 'حذف',
|
||||
'common.edit': 'تعديل',
|
||||
'common.add': 'إضافة',
|
||||
@@ -1809,14 +1810,26 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
|
||||
'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.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,25 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
|
||||
'admin.notifications.adminWebhookPanel.testSuccess': 'تم إرسال Webhook الاختباري بنجاح',
|
||||
'admin.notifications.adminWebhookPanel.testFailed': 'فشل إرسال Webhook الاختباري',
|
||||
'admin.notifications.adminWebhookPanel.alwaysOnHint': 'يُرسل Webhook المسؤول تلقائيًا عند تعيين رابط URL',
|
||||
'admin.notifications.ntfy': 'Ntfy',
|
||||
'admin.ntfy.hint': 'تسمح للمستخدمين بإعداد موضوعات 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.serverHint': 'يُستخدم أيضًا كخادم افتراضي لإشعارات ntfy للمستخدمين. اتركه فارغًا لاستخدام ntfy.sh. يمكن للمستخدمين تغييره في إعداداتهم الخاصة.',
|
||||
'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.tokenCleared': 'تم مسح رمز وصول المسؤول',
|
||||
'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': 'تحديث متاح',
|
||||
|
||||
@@ -4,6 +4,7 @@ const br: Record<string, string | { name: string; category: string }[]> = {
|
||||
'common.showMore': 'Mostrar mais',
|
||||
'common.showLess': 'Mostrar menos',
|
||||
'common.cancel': 'Cancelar',
|
||||
'common.clear': 'Limpar',
|
||||
'common.delete': 'Excluir',
|
||||
'common.edit': 'Editar',
|
||||
'common.add': 'Adicionar',
|
||||
@@ -1758,14 +1759,26 @@ const br: Record<string, string | { name: string; category: string }[]> = {
|
||||
'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.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,25 @@ const br: Record<string, string | { name: string; category: string }[]> = {
|
||||
'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.ntfy.hint': 'Permite que os usuários configurem seus próprios tópicos ntfy para notificações push. Configure o servidor padrão abaixo para preencher as configurações do usuário.',
|
||||
'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.serverHint': 'Também usado como servidor padrão para notificações ntfy dos usuários. Deixe em branco para usar ntfy.sh. Os usuários podem substituir isso em suas próprias configurações.',
|
||||
'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.tokenCleared': 'Token de acesso admin removido',
|
||||
'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',
|
||||
|
||||
@@ -4,6 +4,7 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
|
||||
'common.showMore': 'Zobrazit více',
|
||||
'common.showLess': 'Zobrazit méně',
|
||||
'common.cancel': 'Zrušit',
|
||||
'common.clear': 'Vymazat',
|
||||
'common.delete': 'Smazat',
|
||||
'common.edit': 'Upravit',
|
||||
'common.add': 'Přidat',
|
||||
@@ -1763,14 +1764,26 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
|
||||
'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.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,25 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
|
||||
'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.ntfy.hint': 'Umožňuje uživatelům nakonfigurovat vlastní témata ntfy pro přijímání push notifikací. Níže nastavte výchozí server pro předvyplnění nastavení uživatelů.',
|
||||
'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.serverHint': 'Slouží také jako výchozí server pro ntfy notifikace uživatelů. Ponechte prázdné pro použití ntfy.sh. Uživatelé si to mohou změnit ve vlastním nastavení.',
|
||||
'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.tokenCleared': 'Přístupový token admina byl vymazán',
|
||||
'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',
|
||||
|
||||
@@ -4,6 +4,7 @@ const de: Record<string, string | { name: string; category: string }[]> = {
|
||||
'common.showMore': 'Mehr anzeigen',
|
||||
'common.showLess': 'Weniger anzeigen',
|
||||
'common.cancel': 'Abbrechen',
|
||||
'common.clear': 'Löschen',
|
||||
'common.delete': 'Löschen',
|
||||
'common.edit': 'Bearbeiten',
|
||||
'common.add': 'Hinzufügen',
|
||||
@@ -1766,14 +1767,26 @@ const de: Record<string, string | { name: string; category: string }[]> = {
|
||||
'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.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,25 @@ const de: Record<string, string | { name: string; category: string }[]> = {
|
||||
'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.ntfy.hint': 'Erlaubt Benutzern, eigene ntfy-Themen für Push-Benachrichtigungen zu konfigurieren. Legen Sie unten den Standardserver fest, um die Benutzereinstellungen vorauszufüllen.',
|
||||
'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.serverHint': 'Wird auch als Standardserver für Benutzer-ntfy-Benachrichtigungen verwendet. Leer lassen für ntfy.sh. Benutzer können dies in ihren eigenen Einstellungen überschreiben.',
|
||||
'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.tokenCleared': 'Admin-Zugriffstoken gelöscht',
|
||||
'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',
|
||||
|
||||
@@ -4,6 +4,7 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
||||
'common.showMore': 'Show more',
|
||||
'common.showLess': 'Show less',
|
||||
'common.cancel': 'Cancel',
|
||||
'common.clear': 'Clear',
|
||||
'common.delete': 'Delete',
|
||||
'common.edit': 'Edit',
|
||||
'common.add': 'Add',
|
||||
@@ -189,25 +190,42 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
||||
'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.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.ntfy.hint': 'Allow users to configure their own ntfy topics for push notifications. Set the default server below to pre-fill user settings.',
|
||||
'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 +236,20 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
||||
'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.serverHint': 'Also used as the default server for user ntfy notifications. Leave blank to default to ntfy.sh. Users can override this in their own settings.',
|
||||
'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.tokenCleared': 'Admin access token cleared',
|
||||
'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.',
|
||||
|
||||
@@ -4,6 +4,7 @@ const es: Record<string, string> = {
|
||||
'common.showMore': 'Ver más',
|
||||
'common.showLess': 'Ver menos',
|
||||
'common.cancel': 'Cancelar',
|
||||
'common.clear': 'Borrar',
|
||||
'common.delete': 'Eliminar',
|
||||
'common.edit': 'Editar',
|
||||
'common.add': 'Añadir',
|
||||
@@ -1768,14 +1769,26 @@ const es: Record<string, string> = {
|
||||
'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.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,25 @@ const es: Record<string, string> = {
|
||||
'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.ntfy.hint': 'Permite a los usuarios configurar sus propios temas ntfy para notificaciones push. Establece el servidor predeterminado a continuación para rellenar automáticamente los ajustes del usuario.',
|
||||
'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.serverHint': 'También se usa como servidor predeterminado para las notificaciones ntfy de los usuarios. Déjalo en blanco para usar ntfy.sh. Los usuarios pueden cambiarlo en sus propios ajustes.',
|
||||
'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.tokenCleared': 'Token de acceso de admin eliminado',
|
||||
'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',
|
||||
|
||||
@@ -4,6 +4,7 @@ const fr: Record<string, string> = {
|
||||
'common.showMore': 'Voir plus',
|
||||
'common.showLess': 'Voir moins',
|
||||
'common.cancel': 'Annuler',
|
||||
'common.clear': 'Effacer',
|
||||
'common.delete': 'Supprimer',
|
||||
'common.edit': 'Modifier',
|
||||
'common.add': 'Ajouter',
|
||||
@@ -1762,14 +1763,26 @@ const fr: Record<string, string> = {
|
||||
'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.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,25 @@ const fr: Record<string, string> = {
|
||||
'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.ntfy.hint': 'Permet aux utilisateurs de configurer leurs propres sujets ntfy pour les notifications push. Définissez le serveur par défaut ci-dessous pour pré-remplir les paramètres utilisateur.',
|
||||
'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.serverHint': 'Utilisé également comme serveur par défaut pour les notifications ntfy des utilisateurs. Laisser vide pour utiliser ntfy.sh. Les utilisateurs peuvent le modifier dans leurs propres paramètres.',
|
||||
'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.tokenCleared': "Jeton d'accès admin effacé",
|
||||
'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',
|
||||
|
||||
@@ -4,6 +4,7 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
|
||||
'common.showMore': 'Továbbiak',
|
||||
'common.showLess': 'Kevesebb',
|
||||
'common.cancel': 'Mégse',
|
||||
'common.clear': 'Törlés',
|
||||
'common.delete': 'Törlés',
|
||||
'common.edit': 'Szerkesztés',
|
||||
'common.add': 'Hozzáadás',
|
||||
@@ -1760,14 +1761,26 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
|
||||
'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.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,25 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
|
||||
'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.ntfy.hint': 'Lehetővé teszi a felhasználóknak, hogy saját ntfy-témáikat konfigurálják push értesítésekhez. Állítsa be az alapértelmezett szervert alább a felhasználói beállítások előre kitöltéséhez.',
|
||||
'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.serverHint': 'Alapértelmezett szerverként is szolgál a felhasználói ntfy értesítésekhez. Üresen hagyva ntfy.sh-t használ. A felhasználók felülírhatják saját beállításaikban.',
|
||||
'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.tokenCleared': 'Admin hozzáférési token törölve',
|
||||
'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',
|
||||
|
||||
@@ -4,6 +4,7 @@ const id: Record<string, string | { name: string; category: string }[]> = {
|
||||
'common.showMore': 'Tampilkan lebih banyak',
|
||||
'common.showLess': 'Tampilkan lebih sedikit',
|
||||
'common.cancel': 'Batal',
|
||||
'common.clear': 'Hapus',
|
||||
'common.delete': 'Hapus',
|
||||
'common.edit': 'Sunting',
|
||||
'common.add': 'Tambah',
|
||||
@@ -189,15 +190,27 @@ const id: Record<string, string | { name: string; category: string }[]> = {
|
||||
'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.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,25 @@ const id: Record<string, string | { name: string; category: string }[]> = {
|
||||
'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.ntfy.hint': 'Memungkinkan pengguna mengonfigurasi topik ntfy mereka sendiri untuk notifikasi push. Tetapkan server default di bawah untuk mengisi setelan pengguna secara otomatis.',
|
||||
'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.serverHint': 'Juga digunakan sebagai server default untuk notifikasi ntfy pengguna. Kosongkan untuk menggunakan ntfy.sh. Pengguna dapat menggantinya di pengaturan mereka sendiri.',
|
||||
'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.tokenCleared': 'Token akses admin dihapus',
|
||||
'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.',
|
||||
|
||||
@@ -4,6 +4,7 @@ const it: Record<string, string | { name: string; category: string }[]> = {
|
||||
'common.showMore': 'Mostra di più',
|
||||
'common.showLess': 'Mostra meno',
|
||||
'common.cancel': 'Annulla',
|
||||
'common.clear': 'Cancella',
|
||||
'common.delete': 'Elimina',
|
||||
'common.edit': 'Modifica',
|
||||
'common.add': 'Aggiungi',
|
||||
@@ -1763,14 +1764,26 @@ const it: Record<string, string | { name: string; category: string }[]> = {
|
||||
'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.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,25 @@ const it: Record<string, string | { name: string; category: string }[]> = {
|
||||
'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.ntfy.hint': 'Consente agli utenti di configurare i propri argomenti ntfy per le notifiche push. Imposta il server predefinito di seguito per precompilare le impostazioni utente.',
|
||||
'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.serverHint': 'Usato anche come server predefinito per le notifiche ntfy degli utenti. Lasciare vuoto per usare ntfy.sh. Gli utenti possono sovrascriverlo nelle proprie impostazioni.',
|
||||
'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.tokenCleared': 'Token di accesso admin rimosso',
|
||||
'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',
|
||||
|
||||
@@ -4,6 +4,7 @@ const nl: Record<string, string> = {
|
||||
'common.showMore': 'Meer tonen',
|
||||
'common.showLess': 'Minder tonen',
|
||||
'common.cancel': 'Annuleren',
|
||||
'common.clear': 'Wissen',
|
||||
'common.delete': 'Verwijderen',
|
||||
'common.edit': 'Bewerken',
|
||||
'common.add': 'Toevoegen',
|
||||
@@ -1762,14 +1763,26 @@ const nl: Record<string, string> = {
|
||||
'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.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,25 @@ const nl: Record<string, string> = {
|
||||
'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.ntfy.hint': 'Hiermee kunnen gebruikers hun eigen ntfy-onderwerpen instellen voor pushmeldingen. Stel de standaardserver hieronder in om de gebruikersinstellingen vooraf in te vullen.',
|
||||
'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.serverHint': 'Wordt ook gebruikt als standaardserver voor ntfy-meldingen van gebruikers. Laat leeg om ntfy.sh te gebruiken. Gebruikers kunnen dit aanpassen in hun eigen instellingen.',
|
||||
'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.tokenCleared': 'Admin-toegangstoken gewist',
|
||||
'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',
|
||||
|
||||
@@ -4,6 +4,7 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
|
||||
'common.showMore': 'Pokaż więcej',
|
||||
'common.showLess': 'Pokaż mniej',
|
||||
'common.cancel': 'Anuluj',
|
||||
'common.clear': 'Wyczyść',
|
||||
'common.delete': 'Usuń',
|
||||
'common.edit': 'Edytuj',
|
||||
'common.add': 'Dodaj',
|
||||
@@ -1596,6 +1597,25 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
|
||||
'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.ntfy.hint': 'Pozwala użytkownikom skonfigurować własne tematy ntfy dla powiadomień push. Ustaw domyślny serwer poniżej, aby wstępnie wypełnić ustawienia użytkownika.',
|
||||
'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.serverHint': 'Używany również jako domyślny serwer dla powiadomień ntfy użytkowników. Pozostaw puste, aby użyć ntfy.sh. Użytkownicy mogą to nadpisać w swoich ustawieniach.',
|
||||
'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.tokenCleared': 'Token dostępu admina wyczyszczony',
|
||||
'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 +1623,26 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
|
||||
'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.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.',
|
||||
|
||||
@@ -4,6 +4,7 @@ const ru: Record<string, string> = {
|
||||
'common.showMore': 'Показать больше',
|
||||
'common.showLess': 'Показать меньше',
|
||||
'common.cancel': 'Отмена',
|
||||
'common.clear': 'Очистить',
|
||||
'common.delete': 'Удалить',
|
||||
'common.edit': 'Редактировать',
|
||||
'common.add': 'Добавить',
|
||||
@@ -1759,14 +1760,26 @@ const ru: Record<string, string> = {
|
||||
'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.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,25 @@ const ru: Record<string, string> = {
|
||||
'admin.notifications.adminWebhookPanel.testSuccess': 'Тестовый вебхук успешно отправлен',
|
||||
'admin.notifications.adminWebhookPanel.testFailed': 'Ошибка тестового вебхука',
|
||||
'admin.notifications.adminWebhookPanel.alwaysOnHint': 'Вебхук администратора отправляется автоматически при наличии URL',
|
||||
'admin.notifications.ntfy': 'Ntfy',
|
||||
'admin.ntfy.hint': 'Позволяет пользователям настраивать собственные темы ntfy для push-уведомлений. Установите сервер по умолчанию ниже, чтобы предварительно заполнить настройки пользователей.',
|
||||
'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.serverHint': 'Также используется как сервер по умолчанию для ntfy-уведомлений пользователей. Оставьте пустым, чтобы использовать ntfy.sh. Пользователи могут изменить это в своих настройках.',
|
||||
'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.tokenCleared': 'Токен доступа администратора очищен',
|
||||
'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': 'Доступно обновление',
|
||||
|
||||
@@ -4,6 +4,7 @@ const zh: Record<string, string> = {
|
||||
'common.showMore': '显示更多',
|
||||
'common.showLess': '收起',
|
||||
'common.cancel': '取消',
|
||||
'common.clear': '清除',
|
||||
'common.delete': '删除',
|
||||
'common.edit': '编辑',
|
||||
'common.add': '添加',
|
||||
@@ -1759,14 +1760,26 @@ const zh: Record<string, string> = {
|
||||
'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.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,25 @@ const zh: Record<string, string> = {
|
||||
'admin.notifications.adminWebhookPanel.testSuccess': '测试 Webhook 发送成功',
|
||||
'admin.notifications.adminWebhookPanel.testFailed': '测试 Webhook 失败',
|
||||
'admin.notifications.adminWebhookPanel.alwaysOnHint': '配置 URL 后管理员 Webhook 自动触发',
|
||||
'admin.notifications.ntfy': 'Ntfy',
|
||||
'admin.ntfy.hint': '允许用户配置自己的 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.serverHint': '同时用作用户 ntfy 通知的默认服务器。留空则默认使用 ntfy.sh。用户可在其自己的设置中覆盖此项。',
|
||||
'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.tokenCleared': '管理员访问令牌已清除',
|
||||
'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': '有可用更新',
|
||||
|
||||
@@ -4,6 +4,7 @@ const zhTw: Record<string, string> = {
|
||||
'common.showMore': '顯示更多',
|
||||
'common.showLess': '收起',
|
||||
'common.cancel': '取消',
|
||||
'common.clear': '清除',
|
||||
'common.delete': '刪除',
|
||||
'common.edit': '編輯',
|
||||
'common.add': '新增',
|
||||
@@ -186,15 +187,27 @@ const zhTw: Record<string, string> = {
|
||||
'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.tokenCleared': '存取權杖已清除',
|
||||
'settings.notificationsDisabled': '通知尚未配置。請聯絡管理員啟用電子郵件或 Webhook 通知。',
|
||||
'settings.notificationsActive': '活躍頻道',
|
||||
'settings.notificationsManagedByAdmin': '通知事件由管理員配置。',
|
||||
@@ -218,6 +231,25 @@ const zhTw: Record<string, string> = {
|
||||
'admin.notifications.adminWebhookPanel.testSuccess': '測試 Webhook 傳送成功',
|
||||
'admin.notifications.adminWebhookPanel.testFailed': '測試 Webhook 傳送失敗',
|
||||
'admin.notifications.adminWebhookPanel.alwaysOnHint': '配置 URL 後,管理員 Webhook 始終觸發',
|
||||
'admin.notifications.ntfy': 'Ntfy',
|
||||
'admin.ntfy.hint': '允許使用者設定自己的 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.serverHint': '同時用作使用者 ntfy 通知的預設伺服器。留空則預設使用 ntfy.sh。使用者可在自己的設定中覆寫此項。',
|
||||
'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.tokenCleared': '管理員存取權杖已清除',
|
||||
'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 配置。',
|
||||
|
||||
@@ -66,6 +66,7 @@ const ADMIN_CHANNEL_LABEL_KEYS: Record<string, string> = {
|
||||
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<typeof useToast> }) {
|
||||
@@ -78,7 +79,7 @@ function AdminNotificationsPanel({ t, toast }: { t: (k: string) => string; toast
|
||||
|
||||
if (!matrix) return <p style={{ fontSize: 12, color: 'var(--text-faint)', fontStyle: 'italic', padding: 16 }}>Loading…</p>
|
||||
|
||||
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 {
|
||||
<p className="text-xs text-slate-400 mt-1">{t('admin.smtp.hint')}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setChannels(!emailActive, webhookActive)}
|
||||
onClick={() => setChannels(!emailActive, webhookActive, ntfyActive)}
|
||||
className="relative inline-flex h-6 w-11 items-center rounded-full transition-colors flex-shrink-0"
|
||||
style={{ background: emailActive ? 'var(--text-primary)' : 'var(--border-primary)' }}
|
||||
>
|
||||
@@ -1283,7 +1285,7 @@ export default function AdminPage(): React.ReactElement {
|
||||
<p className="text-xs text-slate-400 mt-1">{t('admin.webhook.hint')}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setChannels(emailActive, !webhookActive)}
|
||||
onClick={() => setChannels(emailActive, !webhookActive, ntfyActive)}
|
||||
className="relative inline-flex h-6 w-11 items-center rounded-full transition-colors flex-shrink-0"
|
||||
style={{ background: webhookActive ? 'var(--text-primary)' : 'var(--border-primary)' }}
|
||||
>
|
||||
@@ -1293,6 +1295,24 @@ export default function AdminPage(): React.ReactElement {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Ntfy Panel */}
|
||||
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden">
|
||||
<div className="px-6 py-4 flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="font-semibold text-slate-900">{t('admin.notifications.ntfy')}</h2>
|
||||
<p className="text-xs text-slate-400 mt-1">{t('admin.ntfy.hint') || 'Allow users to configure their own ntfy topics for push notifications.'}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setChannels(emailActive, webhookActive, !ntfyActive)}
|
||||
className="relative inline-flex h-6 w-11 items-center rounded-full transition-colors flex-shrink-0"
|
||||
style={{ background: ntfyActive ? 'var(--text-primary)' : 'var(--border-primary)' }}
|
||||
>
|
||||
<span className="absolute left-0.5 h-5 w-5 rounded-full bg-white transition-transform duration-200"
|
||||
style={{ transform: ntfyActive ? 'translateX(20px)' : 'translateX(0)' }} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* In-App Panel */}
|
||||
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden">
|
||||
<div className="px-6 py-4 border-b border-slate-100 flex items-center justify-between">
|
||||
@@ -1358,6 +1378,106 @@ export default function AdminPage(): React.ReactElement {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Admin Ntfy Panel */}
|
||||
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden">
|
||||
<div className="px-6 py-4 border-b border-slate-100">
|
||||
<h2 className="font-semibold text-slate-900">{t('admin.notifications.adminNtfyPanel.title')}</h2>
|
||||
<p className="text-xs text-slate-400 mt-1">{t('admin.notifications.adminNtfyPanel.hint')}</p>
|
||||
</div>
|
||||
<div className="p-6 space-y-3">
|
||||
{smtpLoaded && (
|
||||
<>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-slate-500 mb-1">{t('admin.notifications.adminNtfyPanel.serverLabel')}</label>
|
||||
<input
|
||||
type="text"
|
||||
value={smtpValues.admin_ntfy_server || ''}
|
||||
onChange={e => 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"
|
||||
/>
|
||||
<p className="text-xs text-slate-400 mt-1">{t('admin.notifications.adminNtfyPanel.serverHint')}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-slate-500 mb-1">{t('admin.notifications.adminNtfyPanel.topicLabel')}</label>
|
||||
<input
|
||||
type="text"
|
||||
value={smtpValues.admin_ntfy_topic || ''}
|
||||
onChange={e => 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"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-slate-500 mb-1">{t('admin.notifications.adminNtfyPanel.tokenLabel')}</label>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="password"
|
||||
value={smtpValues.admin_ntfy_token === '••••••••' ? '' : smtpValues.admin_ntfy_token || ''}
|
||||
onChange={e => setSmtpValues(prev => ({ ...prev, admin_ntfy_token: e.target.value }))}
|
||||
placeholder={smtpValues.admin_ntfy_token === '••••••••' ? '••••••••' : ''}
|
||||
className="flex-1 px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent"
|
||||
/>
|
||||
{smtpValues.admin_ntfy_token === '••••••••' && (
|
||||
<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')) }
|
||||
}}
|
||||
className="px-3 py-2 border border-red-300 text-red-600 rounded-lg text-sm font-medium hover:bg-red-50 transition-colors"
|
||||
>
|
||||
{t('common.clear')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="px-6 pb-4 flex items-center gap-2 border-t border-slate-100 pt-4">
|
||||
<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 !== '••••••••'
|
||||
? { admin_ntfy_token: smtpValues.admin_ntfy_token }
|
||||
: {}),
|
||||
})
|
||||
toast.success(t('admin.notifications.adminNtfyPanel.saved'))
|
||||
} catch { toast.error(t('common.error')) }
|
||||
}}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-slate-900 text-white rounded-lg text-sm font-medium hover:bg-slate-800 transition-colors">
|
||||
<Save className="w-4 h-4" />{t('common.save')}
|
||||
</button>
|
||||
<button
|
||||
onClick={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')) }
|
||||
}}
|
||||
disabled={!smtpValues.admin_ntfy_topic?.trim()}
|
||||
className="px-4 py-2 border border-slate-300 text-slate-700 rounded-lg text-sm font-medium hover:bg-slate-50 transition-colors disabled:opacity-40"
|
||||
>
|
||||
{t('admin.notifications.adminNtfyPanel.test')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<div className="mt-6">
|
||||
<AdminNotificationsPanel t={t} toast={toast} />
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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<string, string> = {};
|
||||
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;
|
||||
|
||||
@@ -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<NotifEventType, NotifChannel[]> = {
|
||||
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 ─────────────────────────────────────────────
|
||||
@@ -88,6 +89,7 @@ export interface PreferencesMatrix {
|
||||
available_channels: AvailableChannels;
|
||||
event_types: NotifEventType[];
|
||||
implemented_combos: Record<NotifEventType, NotifChannel[]>;
|
||||
defaults?: { ntfyServer: string | null };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -115,8 +117,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 +136,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,
|
||||
};
|
||||
}
|
||||
@@ -149,24 +153,25 @@ export function getPreferencesMatrix(userId: number, userRole: string, scope: 'u
|
||||
available_channels,
|
||||
event_types,
|
||||
implemented_combos: IMPLEMENTED_COMBOS,
|
||||
...(scope === 'user' && { defaults: { ntfyServer: getAppSetting('admin_ntfy_server') || null } }),
|
||||
};
|
||||
}
|
||||
|
||||
// ── 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 +255,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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<void> {
|
||||
}
|
||||
}
|
||||
|
||||
// ── 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<void> {
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ── 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}`);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Record<NotifEventType, { priority: 1 | 2 | 3 | 4 | 5; tags: string[] }>> = {
|
||||
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<string, string> = {};
|
||||
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<boolean> {
|
||||
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<string, string> = {
|
||||
'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' };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<string, unknown> {
|
||||
const rows = db.prepare('SELECT key, value FROM settings WHERE user_id = ?').all(userId) as { key: string; value: string }[];
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<typeof vi.fn>).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<typeof vi.fn>;
|
||||
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<typeof vi.fn>;
|
||||
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<typeof vi.fn>;
|
||||
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<typeof vi.fn>;
|
||||
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<typeof vi.fn>).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('NTFY-006 — HTTP non-2xx response returns false and logs error', async () => {
|
||||
const mockFetch = globalThis.fetch as unknown as ReturnType<typeof vi.fn>;
|
||||
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<typeof vi.fn>;
|
||||
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<typeof vi.fn>;
|
||||
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
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user