mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-19 13:21:46 +00:00
feat(notifications): add ntfy as a first-class notification channel
Adds ntfy.sh (and self-hosted instances) as a new push notification channel with full parity to the existing webhook channel. - Backend: NtfyConfig type, getUserNtfyConfig, getAdminNtfyConfig, resolveNtfyUrl, sendNtfy (header-based API with Title/Priority/Tags/ Click headers), testNtfy, NTFY_EVENT_META (priority + emoji tags per event), SSRF guard via existing checkSsrf + createPinnedDispatcher - notificationPreferencesService: ntfy added to NotifChannel union, IMPLEMENTED_COMBOS, getActiveChannels parser, getAvailableChannels, ADMIN_GLOBAL_CHANNELS, and AvailableChannels interface - notificationService: per-user ntfy dispatch after webhook block; admin-scoped ntfy via getAdminGlobalPref for version_available events - Routes: POST /api/notifications/test-ntfy with saved-token fallback - authService: admin_ntfy_server/topic/token in ADMIN_SETTINGS_KEYS, masked + encrypted on read/write - settingsService: ntfy_token added to ENCRYPTED_SETTING_KEYS - Frontend: ntfy topic/server/token inputs + Save/Test/Clear buttons in NotificationsTab; admin Ntfy panel in AdminPage; testNtfy API method - i18n: full English strings; English placeholders in 14 other locales - Tests: resolveNtfyUrl, sendNtfy, dispatch integration, UI tests, MSW handler for test-ntfy endpoint
This commit is contained in:
@@ -486,6 +486,7 @@ export const notificationsApi = {
|
|||||||
updatePreferences: (prefs: Record<string, Record<string, boolean>>) => apiClient.put('/notifications/preferences', prefs).then(r => r.data),
|
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),
|
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),
|
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 = {
|
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 () => {
|
it('FE-COMP-NOTIFICATIONS-014: failed test webhook shows error toast with message', async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
server.use(
|
server.use(
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import Section from './Section'
|
|||||||
|
|
||||||
interface PreferencesMatrix {
|
interface PreferencesMatrix {
|
||||||
preferences: Record<string, Record<string, boolean>>
|
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[]
|
event_types: string[]
|
||||||
implemented_combos: Record<string, string[]>
|
implemented_combos: Record<string, string[]>
|
||||||
}
|
}
|
||||||
@@ -17,6 +17,7 @@ const CHANNEL_LABEL_KEYS: Record<string, string> = {
|
|||||||
email: 'settings.notificationPreferences.email',
|
email: 'settings.notificationPreferences.email',
|
||||||
webhook: 'settings.notificationPreferences.webhook',
|
webhook: 'settings.notificationPreferences.webhook',
|
||||||
inapp: 'settings.notificationPreferences.inapp',
|
inapp: 'settings.notificationPreferences.inapp',
|
||||||
|
ntfy: 'settings.notificationPreferences.ntfy',
|
||||||
}
|
}
|
||||||
|
|
||||||
const EVENT_LABEL_KEYS: Record<string, string> = {
|
const EVENT_LABEL_KEYS: Record<string, string> = {
|
||||||
@@ -39,6 +40,12 @@ export default function NotificationsTab(): React.ReactElement {
|
|||||||
const [webhookIsSet, setWebhookIsSet] = useState(false)
|
const [webhookIsSet, setWebhookIsSet] = useState(false)
|
||||||
const [webhookSaving, setWebhookSaving] = useState(false)
|
const [webhookSaving, setWebhookSaving] = useState(false)
|
||||||
const [webhookTesting, setWebhookTesting] = 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(() => {
|
useEffect(() => {
|
||||||
notificationsApi.getPreferences().then((data: PreferencesMatrix) => setMatrix(data)).catch(() => {})
|
notificationsApi.getPreferences().then((data: PreferencesMatrix) => setMatrix(data)).catch(() => {})
|
||||||
@@ -50,12 +57,21 @@ export default function NotificationsTab(): React.ReactElement {
|
|||||||
} else {
|
} else {
|
||||||
setWebhookUrl(val)
|
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(() => {})
|
}).catch(() => {})
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const visibleChannels = matrix
|
const visibleChannels = matrix
|
||||||
? (['email', 'webhook', 'inapp'] as const).filter(ch => {
|
? (['email', 'webhook', 'ntfy', 'inapp'] as const).filter(ch => {
|
||||||
if (!matrix.available_channels[ch]) return false
|
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))
|
return matrix.event_types.some(evt => matrix.implemented_combos[evt]?.includes(ch))
|
||||||
})
|
})
|
||||||
: []
|
: []
|
||||||
@@ -106,6 +122,52 @@ export default function NotificationsTab(): React.ReactElement {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const saveNtfySettings = async () => {
|
||||||
|
setNtfySaving(true)
|
||||||
|
try {
|
||||||
|
await settingsApi.setBulk({
|
||||||
|
ntfy_topic: ntfyTopic,
|
||||||
|
ntfy_server: ntfyServer,
|
||||||
|
...(ntfyToken && ntfyToken !== '••••••••' ? { ntfy_token: ntfyToken } : {}),
|
||||||
|
})
|
||||||
|
if (ntfyToken && ntfyToken !== '••••••••') setNtfyTokenIsSet(true)
|
||||||
|
toast.success(t('settings.ntfyUrl.saved'))
|
||||||
|
} catch {
|
||||||
|
toast.error(t('common.error'))
|
||||||
|
} finally {
|
||||||
|
setNtfySaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const clearNtfyToken = async () => {
|
||||||
|
try {
|
||||||
|
await settingsApi.set('ntfy_token', '')
|
||||||
|
setNtfyToken('')
|
||||||
|
setNtfyTokenIsSet(false)
|
||||||
|
toast.success(t('settings.ntfyUrl.tokenCleared'))
|
||||||
|
} catch {
|
||||||
|
toast.error(t('common.error'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const testNtfySettings = async () => {
|
||||||
|
if (!ntfyTopic) return
|
||||||
|
setNtfyTesting(true)
|
||||||
|
try {
|
||||||
|
const result = await notificationsApi.testNtfy({
|
||||||
|
topic: ntfyTopic,
|
||||||
|
server: ntfyServer || null,
|
||||||
|
token: ntfyToken && ntfyToken !== '••••••••' ? ntfyToken : null,
|
||||||
|
})
|
||||||
|
if (result.success) toast.success(t('settings.ntfyUrl.testSuccess'))
|
||||||
|
else toast.error(result.error || t('settings.ntfyUrl.testFailed'))
|
||||||
|
} catch {
|
||||||
|
toast.error(t('settings.ntfyUrl.testFailed'))
|
||||||
|
} finally {
|
||||||
|
setNtfyTesting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const renderContent = () => {
|
const renderContent = () => {
|
||||||
if (!matrix) return <p style={{ fontSize: 12, color: 'var(--text-faint)', fontStyle: 'italic' }}>{t('common.loading')}</p>
|
if (!matrix) return <p style={{ fontSize: 12, color: 'var(--text-faint)', fontStyle: 'italic' }}>{t('common.loading')}</p>
|
||||||
|
|
||||||
@@ -139,7 +201,7 @@ export default function NotificationsTab(): React.ReactElement {
|
|||||||
disabled={webhookSaving}
|
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 }}
|
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>
|
||||||
<button
|
<button
|
||||||
onClick={testWebhookUrl}
|
onClick={testWebhookUrl}
|
||||||
@@ -151,6 +213,66 @@ export default function NotificationsTab(): React.ReactElement {
|
|||||||
</div>
|
</div>
|
||||||
</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={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('settings.ntfyUrl.clearToken')}
|
||||||
|
</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 */}
|
{/* Header row */}
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: `1fr ${visibleChannels.map(() => '64px').join(' ')}`, gap: 4, paddingBottom: 6, marginBottom: 4, borderBottom: '1px solid var(--border-primary)' }}>
|
<div style={{ display: 'grid', gridTemplateColumns: `1fr ${visibleChannels.map(() => '64px').join(' ')}`, gap: 4, paddingBottom: 6, marginBottom: 4, borderBottom: '1px solid var(--border-primary)' }}>
|
||||||
<span />
|
<span />
|
||||||
|
|||||||
@@ -1809,14 +1809,27 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'settings.webhookUrl.label': 'رابط Webhook',
|
'settings.webhookUrl.label': 'رابط Webhook',
|
||||||
'settings.webhookUrl.placeholder': 'https://discord.com/api/webhooks/...',
|
'settings.webhookUrl.placeholder': 'https://discord.com/api/webhooks/...',
|
||||||
'settings.webhookUrl.hint': 'أدخل رابط Webhook الخاص بـ Discord أو Slack أو المخصص لتلقي الإشعارات.',
|
'settings.webhookUrl.hint': 'أدخل رابط Webhook الخاص بـ Discord أو Slack أو المخصص لتلقي الإشعارات.',
|
||||||
'settings.webhookUrl.save': 'حفظ',
|
|
||||||
'settings.webhookUrl.saved': 'تم حفظ رابط Webhook',
|
'settings.webhookUrl.saved': 'تم حفظ رابط Webhook',
|
||||||
'settings.webhookUrl.test': 'اختبار',
|
'settings.webhookUrl.test': 'اختبار',
|
||||||
'settings.webhookUrl.testSuccess': 'تم إرسال Webhook الاختباري بنجاح',
|
'settings.webhookUrl.testSuccess': 'تم إرسال Webhook الاختباري بنجاح',
|
||||||
'settings.webhookUrl.testFailed': 'فشل إرسال Webhook الاختباري',
|
'settings.webhookUrl.testFailed': 'فشل إرسال Webhook الاختباري',
|
||||||
|
'settings.ntfyUrl.topicLabel': 'Ntfy Topic',
|
||||||
|
'settings.ntfyUrl.topicPlaceholder': 'my-trek-alerts',
|
||||||
|
'settings.ntfyUrl.serverLabel': 'Ntfy Server URL (optional)',
|
||||||
|
'settings.ntfyUrl.serverPlaceholder': 'https://ntfy.sh',
|
||||||
|
'settings.ntfyUrl.hint': 'Enter your ntfy topic to receive push notifications. Leave server blank to use the default configured by your admin.',
|
||||||
|
'settings.ntfyUrl.tokenLabel': 'Access Token (optional)',
|
||||||
|
'settings.ntfyUrl.tokenHint': 'Required for password-protected topics.',
|
||||||
|
'settings.ntfyUrl.saved': 'Ntfy settings saved',
|
||||||
|
'settings.ntfyUrl.test': 'Test',
|
||||||
|
'settings.ntfyUrl.testSuccess': 'Test ntfy notification sent successfully',
|
||||||
|
'settings.ntfyUrl.testFailed': 'Test ntfy notification failed',
|
||||||
|
'settings.ntfyUrl.clearToken': 'Clear',
|
||||||
|
'settings.ntfyUrl.tokenCleared': 'Access token cleared',
|
||||||
'settings.notificationPreferences.inapp': 'In-App',
|
'settings.notificationPreferences.inapp': 'In-App',
|
||||||
'settings.notificationPreferences.webhook': 'Webhook',
|
'settings.notificationPreferences.webhook': 'Webhook',
|
||||||
'settings.notificationPreferences.email': 'Email',
|
'settings.notificationPreferences.email': 'Email',
|
||||||
|
'settings.notificationPreferences.ntfy': 'Ntfy',
|
||||||
'admin.notifications.emailPanel.title': 'Email (SMTP)',
|
'admin.notifications.emailPanel.title': 'Email (SMTP)',
|
||||||
'admin.notifications.webhookPanel.title': 'Webhook',
|
'admin.notifications.webhookPanel.title': 'Webhook',
|
||||||
'admin.notifications.inappPanel.title': 'In-App',
|
'admin.notifications.inappPanel.title': 'In-App',
|
||||||
@@ -1827,6 +1840,22 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'admin.notifications.adminWebhookPanel.testSuccess': 'تم إرسال Webhook الاختباري بنجاح',
|
'admin.notifications.adminWebhookPanel.testSuccess': 'تم إرسال Webhook الاختباري بنجاح',
|
||||||
'admin.notifications.adminWebhookPanel.testFailed': 'فشل إرسال Webhook الاختباري',
|
'admin.notifications.adminWebhookPanel.testFailed': 'فشل إرسال Webhook الاختباري',
|
||||||
'admin.notifications.adminWebhookPanel.alwaysOnHint': 'يُرسل Webhook المسؤول تلقائيًا عند تعيين رابط URL',
|
'admin.notifications.adminWebhookPanel.alwaysOnHint': 'يُرسل Webhook المسؤول تلقائيًا عند تعيين رابط URL',
|
||||||
|
'admin.notifications.ntfy': 'Ntfy',
|
||||||
|
'admin.notifications.testNtfy': 'Send test ntfy',
|
||||||
|
'admin.notifications.testNtfySuccess': 'Test ntfy sent successfully',
|
||||||
|
'admin.notifications.testNtfyFailed': 'Test ntfy failed',
|
||||||
|
'admin.notifications.adminNtfyPanel.title': 'Admin Ntfy',
|
||||||
|
'admin.notifications.adminNtfyPanel.hint': 'This ntfy topic is used exclusively for admin notifications (e.g. version alerts). It is separate from per-user topics and always fires when configured.',
|
||||||
|
'admin.notifications.adminNtfyPanel.serverLabel': 'Ntfy Server URL',
|
||||||
|
'admin.notifications.adminNtfyPanel.serverPlaceholder': 'https://ntfy.sh',
|
||||||
|
'admin.notifications.adminNtfyPanel.topicLabel': 'Admin Topic',
|
||||||
|
'admin.notifications.adminNtfyPanel.topicPlaceholder': 'trek-admin-alerts',
|
||||||
|
'admin.notifications.adminNtfyPanel.tokenLabel': 'Access Token (optional)',
|
||||||
|
'admin.notifications.adminNtfyPanel.saved': 'Admin ntfy settings saved',
|
||||||
|
'admin.notifications.adminNtfyPanel.test': 'Send test ntfy',
|
||||||
|
'admin.notifications.adminNtfyPanel.testSuccess': 'Test ntfy sent successfully',
|
||||||
|
'admin.notifications.adminNtfyPanel.testFailed': 'Test ntfy failed',
|
||||||
|
'admin.notifications.adminNtfyPanel.alwaysOnHint': 'Admin ntfy always fires when a topic is configured',
|
||||||
'admin.notifications.adminNotificationsHint': 'حدد القنوات التي تُسلّم إشعارات المسؤول (مثل تنبيهات الإصدارات). يُرسل الـ Webhook تلقائيًا عند تعيين رابط URL لـ Webhook المسؤول.',
|
'admin.notifications.adminNotificationsHint': 'حدد القنوات التي تُسلّم إشعارات المسؤول (مثل تنبيهات الإصدارات). يُرسل الـ Webhook تلقائيًا عند تعيين رابط URL لـ Webhook المسؤول.',
|
||||||
'admin.tabs.notifications': 'الإشعارات',
|
'admin.tabs.notifications': 'الإشعارات',
|
||||||
'notifications.versionAvailable.title': 'تحديث متاح',
|
'notifications.versionAvailable.title': 'تحديث متاح',
|
||||||
|
|||||||
@@ -1758,14 +1758,27 @@ const br: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'settings.webhookUrl.label': 'URL do webhook',
|
'settings.webhookUrl.label': 'URL do webhook',
|
||||||
'settings.webhookUrl.placeholder': 'https://discord.com/api/webhooks/...',
|
'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.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.saved': 'URL do webhook salva',
|
||||||
'settings.webhookUrl.test': 'Testar',
|
'settings.webhookUrl.test': 'Testar',
|
||||||
'settings.webhookUrl.testSuccess': 'Webhook de teste enviado com sucesso',
|
'settings.webhookUrl.testSuccess': 'Webhook de teste enviado com sucesso',
|
||||||
'settings.webhookUrl.testFailed': 'Falha no webhook de teste',
|
'settings.webhookUrl.testFailed': 'Falha no webhook de teste',
|
||||||
|
'settings.ntfyUrl.topicLabel': 'Ntfy Topic',
|
||||||
|
'settings.ntfyUrl.topicPlaceholder': 'my-trek-alerts',
|
||||||
|
'settings.ntfyUrl.serverLabel': 'Ntfy Server URL (optional)',
|
||||||
|
'settings.ntfyUrl.serverPlaceholder': 'https://ntfy.sh',
|
||||||
|
'settings.ntfyUrl.hint': 'Enter your ntfy topic to receive push notifications. Leave server blank to use the default configured by your admin.',
|
||||||
|
'settings.ntfyUrl.tokenLabel': 'Access Token (optional)',
|
||||||
|
'settings.ntfyUrl.tokenHint': 'Required for password-protected topics.',
|
||||||
|
'settings.ntfyUrl.saved': 'Ntfy settings saved',
|
||||||
|
'settings.ntfyUrl.test': 'Test',
|
||||||
|
'settings.ntfyUrl.testSuccess': 'Test ntfy notification sent successfully',
|
||||||
|
'settings.ntfyUrl.testFailed': 'Test ntfy notification failed',
|
||||||
|
'settings.ntfyUrl.clearToken': 'Clear',
|
||||||
|
'settings.ntfyUrl.tokenCleared': 'Access token cleared',
|
||||||
'settings.notificationPreferences.inapp': 'In-App',
|
'settings.notificationPreferences.inapp': 'In-App',
|
||||||
'settings.notificationPreferences.webhook': 'Webhook',
|
'settings.notificationPreferences.webhook': 'Webhook',
|
||||||
'settings.notificationPreferences.email': 'Email',
|
'settings.notificationPreferences.email': 'Email',
|
||||||
|
'settings.notificationPreferences.ntfy': 'Ntfy',
|
||||||
'admin.notifications.emailPanel.title': 'Email (SMTP)',
|
'admin.notifications.emailPanel.title': 'Email (SMTP)',
|
||||||
'admin.notifications.webhookPanel.title': 'Webhook',
|
'admin.notifications.webhookPanel.title': 'Webhook',
|
||||||
'admin.notifications.inappPanel.title': 'In-App',
|
'admin.notifications.inappPanel.title': 'In-App',
|
||||||
@@ -1776,6 +1789,22 @@ const br: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'admin.notifications.adminWebhookPanel.testSuccess': 'Webhook de teste enviado com sucesso',
|
'admin.notifications.adminWebhookPanel.testSuccess': 'Webhook de teste enviado com sucesso',
|
||||||
'admin.notifications.adminWebhookPanel.testFailed': 'Falha no webhook de teste',
|
'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.adminWebhookPanel.alwaysOnHint': 'O webhook de admin dispara automaticamente quando uma URL está configurada',
|
||||||
|
'admin.notifications.ntfy': 'Ntfy',
|
||||||
|
'admin.notifications.testNtfy': 'Send test ntfy',
|
||||||
|
'admin.notifications.testNtfySuccess': 'Test ntfy sent successfully',
|
||||||
|
'admin.notifications.testNtfyFailed': 'Test ntfy failed',
|
||||||
|
'admin.notifications.adminNtfyPanel.title': 'Admin Ntfy',
|
||||||
|
'admin.notifications.adminNtfyPanel.hint': 'This ntfy topic is used exclusively for admin notifications (e.g. version alerts). It is separate from per-user topics and always fires when configured.',
|
||||||
|
'admin.notifications.adminNtfyPanel.serverLabel': 'Ntfy Server URL',
|
||||||
|
'admin.notifications.adminNtfyPanel.serverPlaceholder': 'https://ntfy.sh',
|
||||||
|
'admin.notifications.adminNtfyPanel.topicLabel': 'Admin Topic',
|
||||||
|
'admin.notifications.adminNtfyPanel.topicPlaceholder': 'trek-admin-alerts',
|
||||||
|
'admin.notifications.adminNtfyPanel.tokenLabel': 'Access Token (optional)',
|
||||||
|
'admin.notifications.adminNtfyPanel.saved': 'Admin ntfy settings saved',
|
||||||
|
'admin.notifications.adminNtfyPanel.test': 'Send test ntfy',
|
||||||
|
'admin.notifications.adminNtfyPanel.testSuccess': 'Test ntfy sent successfully',
|
||||||
|
'admin.notifications.adminNtfyPanel.testFailed': 'Test ntfy failed',
|
||||||
|
'admin.notifications.adminNtfyPanel.alwaysOnHint': 'Admin ntfy always fires when a topic is configured',
|
||||||
'admin.notifications.adminNotificationsHint': 'Configure 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.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',
|
'admin.tabs.notifications': 'Notificações',
|
||||||
'notifications.versionAvailable.title': 'Atualização disponível',
|
'notifications.versionAvailable.title': 'Atualização disponível',
|
||||||
|
|||||||
@@ -1763,14 +1763,27 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'settings.webhookUrl.label': 'URL webhooku',
|
'settings.webhookUrl.label': 'URL webhooku',
|
||||||
'settings.webhookUrl.placeholder': 'https://discord.com/api/webhooks/...',
|
'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.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.saved': 'URL webhooku uložena',
|
||||||
'settings.webhookUrl.test': 'Otestovat',
|
'settings.webhookUrl.test': 'Otestovat',
|
||||||
'settings.webhookUrl.testSuccess': 'Testovací webhook byl úspěšně odeslán',
|
'settings.webhookUrl.testSuccess': 'Testovací webhook byl úspěšně odeslán',
|
||||||
'settings.webhookUrl.testFailed': 'Testovací webhook selhal',
|
'settings.webhookUrl.testFailed': 'Testovací webhook selhal',
|
||||||
|
'settings.ntfyUrl.topicLabel': 'Ntfy Topic',
|
||||||
|
'settings.ntfyUrl.topicPlaceholder': 'my-trek-alerts',
|
||||||
|
'settings.ntfyUrl.serverLabel': 'Ntfy Server URL (optional)',
|
||||||
|
'settings.ntfyUrl.serverPlaceholder': 'https://ntfy.sh',
|
||||||
|
'settings.ntfyUrl.hint': 'Enter your ntfy topic to receive push notifications. Leave server blank to use the default configured by your admin.',
|
||||||
|
'settings.ntfyUrl.tokenLabel': 'Access Token (optional)',
|
||||||
|
'settings.ntfyUrl.tokenHint': 'Required for password-protected topics.',
|
||||||
|
'settings.ntfyUrl.saved': 'Ntfy settings saved',
|
||||||
|
'settings.ntfyUrl.test': 'Test',
|
||||||
|
'settings.ntfyUrl.testSuccess': 'Test ntfy notification sent successfully',
|
||||||
|
'settings.ntfyUrl.testFailed': 'Test ntfy notification failed',
|
||||||
|
'settings.ntfyUrl.clearToken': 'Clear',
|
||||||
|
'settings.ntfyUrl.tokenCleared': 'Access token cleared',
|
||||||
'settings.notificationPreferences.inapp': 'In-App',
|
'settings.notificationPreferences.inapp': 'In-App',
|
||||||
'settings.notificationPreferences.webhook': 'Webhook',
|
'settings.notificationPreferences.webhook': 'Webhook',
|
||||||
'settings.notificationPreferences.email': 'Email',
|
'settings.notificationPreferences.email': 'Email',
|
||||||
|
'settings.notificationPreferences.ntfy': 'Ntfy',
|
||||||
'admin.notifications.emailPanel.title': 'Email (SMTP)',
|
'admin.notifications.emailPanel.title': 'Email (SMTP)',
|
||||||
'admin.notifications.webhookPanel.title': 'Webhook',
|
'admin.notifications.webhookPanel.title': 'Webhook',
|
||||||
'admin.notifications.inappPanel.title': 'In-App',
|
'admin.notifications.inappPanel.title': 'In-App',
|
||||||
@@ -1781,6 +1794,22 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'admin.notifications.adminWebhookPanel.testSuccess': 'Testovací webhook byl úspěšně odeslán',
|
'admin.notifications.adminWebhookPanel.testSuccess': 'Testovací webhook byl úspěšně odeslán',
|
||||||
'admin.notifications.adminWebhookPanel.testFailed': 'Testovací webhook selhal',
|
'admin.notifications.adminWebhookPanel.testFailed': 'Testovací webhook selhal',
|
||||||
'admin.notifications.adminWebhookPanel.alwaysOnHint': 'Admin webhook odesílá automaticky, pokud je nastavena URL',
|
'admin.notifications.adminWebhookPanel.alwaysOnHint': 'Admin webhook odesílá automaticky, pokud je nastavena URL',
|
||||||
|
'admin.notifications.ntfy': 'Ntfy',
|
||||||
|
'admin.notifications.testNtfy': 'Send test ntfy',
|
||||||
|
'admin.notifications.testNtfySuccess': 'Test ntfy sent successfully',
|
||||||
|
'admin.notifications.testNtfyFailed': 'Test ntfy failed',
|
||||||
|
'admin.notifications.adminNtfyPanel.title': 'Admin Ntfy',
|
||||||
|
'admin.notifications.adminNtfyPanel.hint': 'This ntfy topic is used exclusively for admin notifications (e.g. version alerts). It is separate from per-user topics and always fires when configured.',
|
||||||
|
'admin.notifications.adminNtfyPanel.serverLabel': 'Ntfy Server URL',
|
||||||
|
'admin.notifications.adminNtfyPanel.serverPlaceholder': 'https://ntfy.sh',
|
||||||
|
'admin.notifications.adminNtfyPanel.topicLabel': 'Admin Topic',
|
||||||
|
'admin.notifications.adminNtfyPanel.topicPlaceholder': 'trek-admin-alerts',
|
||||||
|
'admin.notifications.adminNtfyPanel.tokenLabel': 'Access Token (optional)',
|
||||||
|
'admin.notifications.adminNtfyPanel.saved': 'Admin ntfy settings saved',
|
||||||
|
'admin.notifications.adminNtfyPanel.test': 'Send test ntfy',
|
||||||
|
'admin.notifications.adminNtfyPanel.testSuccess': 'Test ntfy sent successfully',
|
||||||
|
'admin.notifications.adminNtfyPanel.testFailed': 'Test ntfy failed',
|
||||||
|
'admin.notifications.adminNtfyPanel.alwaysOnHint': 'Admin ntfy always fires when a topic is configured',
|
||||||
'admin.notifications.adminNotificationsHint': '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.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í',
|
'admin.tabs.notifications': 'Oznámení',
|
||||||
'notifications.versionAvailable.title': 'Dostupná aktualizace',
|
'notifications.versionAvailable.title': 'Dostupná aktualizace',
|
||||||
|
|||||||
@@ -1766,14 +1766,27 @@ const de: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'settings.webhookUrl.label': 'Webhook-URL',
|
'settings.webhookUrl.label': 'Webhook-URL',
|
||||||
'settings.webhookUrl.placeholder': 'https://discord.com/api/webhooks/...',
|
'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.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.saved': 'Webhook-URL gespeichert',
|
||||||
'settings.webhookUrl.test': 'Testen',
|
'settings.webhookUrl.test': 'Testen',
|
||||||
'settings.webhookUrl.testSuccess': 'Test-Webhook erfolgreich gesendet',
|
'settings.webhookUrl.testSuccess': 'Test-Webhook erfolgreich gesendet',
|
||||||
'settings.webhookUrl.testFailed': 'Test-Webhook fehlgeschlagen',
|
'settings.webhookUrl.testFailed': 'Test-Webhook fehlgeschlagen',
|
||||||
|
'settings.ntfyUrl.topicLabel': 'Ntfy Topic',
|
||||||
|
'settings.ntfyUrl.topicPlaceholder': 'my-trek-alerts',
|
||||||
|
'settings.ntfyUrl.serverLabel': 'Ntfy Server URL (optional)',
|
||||||
|
'settings.ntfyUrl.serverPlaceholder': 'https://ntfy.sh',
|
||||||
|
'settings.ntfyUrl.hint': 'Enter your ntfy topic to receive push notifications. Leave server blank to use the default configured by your admin.',
|
||||||
|
'settings.ntfyUrl.tokenLabel': 'Access Token (optional)',
|
||||||
|
'settings.ntfyUrl.tokenHint': 'Required for password-protected topics.',
|
||||||
|
'settings.ntfyUrl.saved': 'Ntfy settings saved',
|
||||||
|
'settings.ntfyUrl.test': 'Test',
|
||||||
|
'settings.ntfyUrl.testSuccess': 'Test ntfy notification sent successfully',
|
||||||
|
'settings.ntfyUrl.testFailed': 'Test ntfy notification failed',
|
||||||
|
'settings.ntfyUrl.clearToken': 'Clear',
|
||||||
|
'settings.ntfyUrl.tokenCleared': 'Access token cleared',
|
||||||
'settings.notificationPreferences.inapp': 'In-App',
|
'settings.notificationPreferences.inapp': 'In-App',
|
||||||
'settings.notificationPreferences.webhook': 'Webhook',
|
'settings.notificationPreferences.webhook': 'Webhook',
|
||||||
'settings.notificationPreferences.email': 'Email',
|
'settings.notificationPreferences.email': 'Email',
|
||||||
|
'settings.notificationPreferences.ntfy': 'Ntfy',
|
||||||
'admin.notifications.emailPanel.title': 'Email (SMTP)',
|
'admin.notifications.emailPanel.title': 'Email (SMTP)',
|
||||||
'admin.notifications.webhookPanel.title': 'Webhook',
|
'admin.notifications.webhookPanel.title': 'Webhook',
|
||||||
'admin.notifications.inappPanel.title': 'In-App',
|
'admin.notifications.inappPanel.title': 'In-App',
|
||||||
@@ -1784,6 +1797,22 @@ const de: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'admin.notifications.adminWebhookPanel.testSuccess': 'Test-Webhook erfolgreich gesendet',
|
'admin.notifications.adminWebhookPanel.testSuccess': 'Test-Webhook erfolgreich gesendet',
|
||||||
'admin.notifications.adminWebhookPanel.testFailed': 'Test-Webhook fehlgeschlagen',
|
'admin.notifications.adminWebhookPanel.testFailed': 'Test-Webhook fehlgeschlagen',
|
||||||
'admin.notifications.adminWebhookPanel.alwaysOnHint': 'Admin-Webhook sendet automatisch, wenn eine URL konfiguriert ist',
|
'admin.notifications.adminWebhookPanel.alwaysOnHint': 'Admin-Webhook sendet automatisch, wenn eine URL konfiguriert ist',
|
||||||
|
'admin.notifications.ntfy': 'Ntfy',
|
||||||
|
'admin.notifications.testNtfy': 'Send test ntfy',
|
||||||
|
'admin.notifications.testNtfySuccess': 'Test ntfy sent successfully',
|
||||||
|
'admin.notifications.testNtfyFailed': 'Test ntfy failed',
|
||||||
|
'admin.notifications.adminNtfyPanel.title': 'Admin Ntfy',
|
||||||
|
'admin.notifications.adminNtfyPanel.hint': 'This ntfy topic is used exclusively for admin notifications (e.g. version alerts). It is separate from per-user topics and always fires when configured.',
|
||||||
|
'admin.notifications.adminNtfyPanel.serverLabel': 'Ntfy Server URL',
|
||||||
|
'admin.notifications.adminNtfyPanel.serverPlaceholder': 'https://ntfy.sh',
|
||||||
|
'admin.notifications.adminNtfyPanel.topicLabel': 'Admin Topic',
|
||||||
|
'admin.notifications.adminNtfyPanel.topicPlaceholder': 'trek-admin-alerts',
|
||||||
|
'admin.notifications.adminNtfyPanel.tokenLabel': 'Access Token (optional)',
|
||||||
|
'admin.notifications.adminNtfyPanel.saved': 'Admin ntfy settings saved',
|
||||||
|
'admin.notifications.adminNtfyPanel.test': 'Send test ntfy',
|
||||||
|
'admin.notifications.adminNtfyPanel.testSuccess': 'Test ntfy sent successfully',
|
||||||
|
'admin.notifications.adminNtfyPanel.testFailed': 'Test ntfy failed',
|
||||||
|
'admin.notifications.adminNtfyPanel.alwaysOnHint': 'Admin ntfy always fires when a topic is configured',
|
||||||
'admin.notifications.adminNotificationsHint': 'Konfiguriere, welche Kanäle Admin-Benachrichtigungen liefern (z. B. Versions-Updates). Der Webhook sendet automatisch, wenn eine Admin-Webhook-URL gesetzt 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',
|
'admin.tabs.notifications': 'Benachrichtigungen',
|
||||||
'notifications.versionAvailable.title': 'Update verfügbar',
|
'notifications.versionAvailable.title': 'Update verfügbar',
|
||||||
|
|||||||
@@ -189,25 +189,42 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'settings.notificationPreferences.email': 'Email',
|
'settings.notificationPreferences.email': 'Email',
|
||||||
'settings.notificationPreferences.webhook': 'Webhook',
|
'settings.notificationPreferences.webhook': 'Webhook',
|
||||||
'settings.notificationPreferences.inapp': 'In-App',
|
'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.notificationPreferences.noChannels': 'No notification channels are configured. Ask an admin to set up email or webhook notifications.',
|
||||||
'settings.webhookUrl.label': 'Webhook URL',
|
'settings.webhookUrl.label': 'Webhook URL',
|
||||||
'settings.webhookUrl.placeholder': 'https://discord.com/api/webhooks/...',
|
'settings.webhookUrl.placeholder': 'https://discord.com/api/webhooks/...',
|
||||||
'settings.webhookUrl.hint': 'Enter your Discord, Slack, or custom webhook URL to receive notifications.',
|
'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.saved': 'Webhook URL saved',
|
||||||
'settings.webhookUrl.test': 'Test',
|
'settings.webhookUrl.test': 'Test',
|
||||||
'settings.webhookUrl.testSuccess': 'Test webhook sent successfully',
|
'settings.webhookUrl.testSuccess': 'Test webhook sent successfully',
|
||||||
'settings.webhookUrl.testFailed': 'Test webhook failed',
|
'settings.webhookUrl.testFailed': 'Test webhook failed',
|
||||||
|
'settings.ntfyUrl.topicLabel': 'Ntfy Topic',
|
||||||
|
'settings.ntfyUrl.topicPlaceholder': 'my-trek-alerts',
|
||||||
|
'settings.ntfyUrl.serverLabel': 'Ntfy Server URL (optional)',
|
||||||
|
'settings.ntfyUrl.serverPlaceholder': 'https://ntfy.sh',
|
||||||
|
'settings.ntfyUrl.hint': 'Enter your ntfy topic to receive push notifications. Leave server blank to use the default configured by your admin.',
|
||||||
|
'settings.ntfyUrl.tokenLabel': 'Access Token (optional)',
|
||||||
|
'settings.ntfyUrl.tokenHint': 'Required for password-protected topics.',
|
||||||
|
'settings.ntfyUrl.saved': 'Ntfy settings saved',
|
||||||
|
'settings.ntfyUrl.test': 'Test',
|
||||||
|
'settings.ntfyUrl.testSuccess': 'Test ntfy notification sent successfully',
|
||||||
|
'settings.ntfyUrl.testFailed': 'Test ntfy notification failed',
|
||||||
|
'settings.ntfyUrl.clearToken': 'Clear',
|
||||||
|
'settings.ntfyUrl.tokenCleared': 'Access token cleared',
|
||||||
'admin.notifications.title': 'Notifications',
|
'admin.notifications.title': 'Notifications',
|
||||||
'admin.notifications.hint': 'Choose one notification channel. Only one can be active at a time.',
|
'admin.notifications.hint': 'Choose one notification channel. Only one can be active at a time.',
|
||||||
'admin.notifications.none': 'Disabled',
|
'admin.notifications.none': 'Disabled',
|
||||||
'admin.notifications.email': 'Email (SMTP)',
|
'admin.notifications.email': 'Email (SMTP)',
|
||||||
'admin.notifications.webhook': 'Webhook',
|
'admin.notifications.webhook': 'Webhook',
|
||||||
|
'admin.notifications.ntfy': 'Ntfy',
|
||||||
'admin.notifications.save': 'Save notification settings',
|
'admin.notifications.save': 'Save notification settings',
|
||||||
'admin.notifications.saved': 'Notification settings saved',
|
'admin.notifications.saved': 'Notification settings saved',
|
||||||
'admin.notifications.testWebhook': 'Send test webhook',
|
'admin.notifications.testWebhook': 'Send test webhook',
|
||||||
'admin.notifications.testWebhookSuccess': 'Test webhook sent successfully',
|
'admin.notifications.testWebhookSuccess': 'Test webhook sent successfully',
|
||||||
'admin.notifications.testWebhookFailed': 'Test webhook failed',
|
'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.emailPanel.title': 'Email (SMTP)',
|
||||||
'admin.notifications.webhookPanel.title': 'Webhook',
|
'admin.notifications.webhookPanel.title': 'Webhook',
|
||||||
'admin.notifications.inappPanel.title': 'In-App',
|
'admin.notifications.inappPanel.title': 'In-App',
|
||||||
@@ -218,6 +235,18 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'admin.notifications.adminWebhookPanel.testSuccess': 'Test webhook sent successfully',
|
'admin.notifications.adminWebhookPanel.testSuccess': 'Test webhook sent successfully',
|
||||||
'admin.notifications.adminWebhookPanel.testFailed': 'Test webhook failed',
|
'admin.notifications.adminWebhookPanel.testFailed': 'Test webhook failed',
|
||||||
'admin.notifications.adminWebhookPanel.alwaysOnHint': 'Admin webhook always fires when a URL is configured',
|
'admin.notifications.adminWebhookPanel.alwaysOnHint': 'Admin webhook always fires when a URL is configured',
|
||||||
|
'admin.notifications.adminNtfyPanel.title': 'Admin Ntfy',
|
||||||
|
'admin.notifications.adminNtfyPanel.hint': 'This ntfy topic is used exclusively for admin notifications (e.g. version alerts). It is separate from per-user topics and always fires when configured.',
|
||||||
|
'admin.notifications.adminNtfyPanel.serverLabel': 'Ntfy Server URL',
|
||||||
|
'admin.notifications.adminNtfyPanel.serverPlaceholder': 'https://ntfy.sh',
|
||||||
|
'admin.notifications.adminNtfyPanel.topicLabel': 'Admin Topic',
|
||||||
|
'admin.notifications.adminNtfyPanel.topicPlaceholder': 'trek-admin-alerts',
|
||||||
|
'admin.notifications.adminNtfyPanel.tokenLabel': 'Access Token (optional)',
|
||||||
|
'admin.notifications.adminNtfyPanel.saved': 'Admin ntfy settings saved',
|
||||||
|
'admin.notifications.adminNtfyPanel.test': 'Send test ntfy',
|
||||||
|
'admin.notifications.adminNtfyPanel.testSuccess': 'Test ntfy sent successfully',
|
||||||
|
'admin.notifications.adminNtfyPanel.testFailed': 'Test ntfy failed',
|
||||||
|
'admin.notifications.adminNtfyPanel.alwaysOnHint': 'Admin ntfy always fires when a topic is configured',
|
||||||
'admin.notifications.adminNotificationsHint': 'Configure which channels deliver admin-only notifications (e.g. version alerts).',
|
'admin.notifications.adminNotificationsHint': 'Configure which channels deliver admin-only notifications (e.g. version alerts).',
|
||||||
'admin.smtp.title': 'Email & Notifications',
|
'admin.smtp.title': 'Email & Notifications',
|
||||||
'admin.smtp.hint': 'SMTP configuration for sending email notifications.',
|
'admin.smtp.hint': 'SMTP configuration for sending email notifications.',
|
||||||
|
|||||||
@@ -1768,14 +1768,27 @@ const es: Record<string, string> = {
|
|||||||
'settings.webhookUrl.label': 'URL del webhook',
|
'settings.webhookUrl.label': 'URL del webhook',
|
||||||
'settings.webhookUrl.placeholder': 'https://discord.com/api/webhooks/...',
|
'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.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.saved': 'URL del webhook guardada',
|
||||||
'settings.webhookUrl.test': 'Probar',
|
'settings.webhookUrl.test': 'Probar',
|
||||||
'settings.webhookUrl.testSuccess': 'Webhook de prueba enviado correctamente',
|
'settings.webhookUrl.testSuccess': 'Webhook de prueba enviado correctamente',
|
||||||
'settings.webhookUrl.testFailed': 'Error al enviar el webhook de prueba',
|
'settings.webhookUrl.testFailed': 'Error al enviar el webhook de prueba',
|
||||||
|
'settings.ntfyUrl.topicLabel': 'Ntfy Topic',
|
||||||
|
'settings.ntfyUrl.topicPlaceholder': 'my-trek-alerts',
|
||||||
|
'settings.ntfyUrl.serverLabel': 'Ntfy Server URL (optional)',
|
||||||
|
'settings.ntfyUrl.serverPlaceholder': 'https://ntfy.sh',
|
||||||
|
'settings.ntfyUrl.hint': 'Enter your ntfy topic to receive push notifications. Leave server blank to use the default configured by your admin.',
|
||||||
|
'settings.ntfyUrl.tokenLabel': 'Access Token (optional)',
|
||||||
|
'settings.ntfyUrl.tokenHint': 'Required for password-protected topics.',
|
||||||
|
'settings.ntfyUrl.saved': 'Ntfy settings saved',
|
||||||
|
'settings.ntfyUrl.test': 'Test',
|
||||||
|
'settings.ntfyUrl.testSuccess': 'Test ntfy notification sent successfully',
|
||||||
|
'settings.ntfyUrl.testFailed': 'Test ntfy notification failed',
|
||||||
|
'settings.ntfyUrl.clearToken': 'Clear',
|
||||||
|
'settings.ntfyUrl.tokenCleared': 'Access token cleared',
|
||||||
'settings.notificationPreferences.inapp': 'In-App',
|
'settings.notificationPreferences.inapp': 'In-App',
|
||||||
'settings.notificationPreferences.webhook': 'Webhook',
|
'settings.notificationPreferences.webhook': 'Webhook',
|
||||||
'settings.notificationPreferences.email': 'Email',
|
'settings.notificationPreferences.email': 'Email',
|
||||||
|
'settings.notificationPreferences.ntfy': 'Ntfy',
|
||||||
'admin.notifications.emailPanel.title': 'Email (SMTP)',
|
'admin.notifications.emailPanel.title': 'Email (SMTP)',
|
||||||
'admin.notifications.webhookPanel.title': 'Webhook',
|
'admin.notifications.webhookPanel.title': 'Webhook',
|
||||||
'admin.notifications.inappPanel.title': 'In-App',
|
'admin.notifications.inappPanel.title': 'In-App',
|
||||||
@@ -1786,6 +1799,22 @@ const es: Record<string, string> = {
|
|||||||
'admin.notifications.adminWebhookPanel.testSuccess': 'Webhook de prueba enviado correctamente',
|
'admin.notifications.adminWebhookPanel.testSuccess': 'Webhook de prueba enviado correctamente',
|
||||||
'admin.notifications.adminWebhookPanel.testFailed': 'Error al enviar el webhook de prueba',
|
'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.adminWebhookPanel.alwaysOnHint': 'El webhook de admin se activa automáticamente si hay una URL configurada',
|
||||||
|
'admin.notifications.ntfy': 'Ntfy',
|
||||||
|
'admin.notifications.testNtfy': 'Send test ntfy',
|
||||||
|
'admin.notifications.testNtfySuccess': 'Test ntfy sent successfully',
|
||||||
|
'admin.notifications.testNtfyFailed': 'Test ntfy failed',
|
||||||
|
'admin.notifications.adminNtfyPanel.title': 'Admin Ntfy',
|
||||||
|
'admin.notifications.adminNtfyPanel.hint': 'This ntfy topic is used exclusively for admin notifications (e.g. version alerts). It is separate from per-user topics and always fires when configured.',
|
||||||
|
'admin.notifications.adminNtfyPanel.serverLabel': 'Ntfy Server URL',
|
||||||
|
'admin.notifications.adminNtfyPanel.serverPlaceholder': 'https://ntfy.sh',
|
||||||
|
'admin.notifications.adminNtfyPanel.topicLabel': 'Admin Topic',
|
||||||
|
'admin.notifications.adminNtfyPanel.topicPlaceholder': 'trek-admin-alerts',
|
||||||
|
'admin.notifications.adminNtfyPanel.tokenLabel': 'Access Token (optional)',
|
||||||
|
'admin.notifications.adminNtfyPanel.saved': 'Admin ntfy settings saved',
|
||||||
|
'admin.notifications.adminNtfyPanel.test': 'Send test ntfy',
|
||||||
|
'admin.notifications.adminNtfyPanel.testSuccess': 'Test ntfy sent successfully',
|
||||||
|
'admin.notifications.adminNtfyPanel.testFailed': 'Test ntfy failed',
|
||||||
|
'admin.notifications.adminNtfyPanel.alwaysOnHint': 'Admin ntfy always fires when a topic is configured',
|
||||||
'admin.notifications.adminNotificationsHint': '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.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',
|
'admin.tabs.notifications': 'Notificaciones',
|
||||||
'notifications.versionAvailable.title': 'Actualización disponible',
|
'notifications.versionAvailable.title': 'Actualización disponible',
|
||||||
|
|||||||
@@ -1762,14 +1762,27 @@ const fr: Record<string, string> = {
|
|||||||
'settings.webhookUrl.label': 'URL du webhook',
|
'settings.webhookUrl.label': 'URL du webhook',
|
||||||
'settings.webhookUrl.placeholder': 'https://discord.com/api/webhooks/...',
|
'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.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.saved': 'URL du webhook enregistrée',
|
||||||
'settings.webhookUrl.test': 'Tester',
|
'settings.webhookUrl.test': 'Tester',
|
||||||
'settings.webhookUrl.testSuccess': 'Webhook de test envoyé avec succès',
|
'settings.webhookUrl.testSuccess': 'Webhook de test envoyé avec succès',
|
||||||
'settings.webhookUrl.testFailed': 'Échec du webhook de test',
|
'settings.webhookUrl.testFailed': 'Échec du webhook de test',
|
||||||
|
'settings.ntfyUrl.topicLabel': 'Ntfy Topic',
|
||||||
|
'settings.ntfyUrl.topicPlaceholder': 'my-trek-alerts',
|
||||||
|
'settings.ntfyUrl.serverLabel': 'Ntfy Server URL (optional)',
|
||||||
|
'settings.ntfyUrl.serverPlaceholder': 'https://ntfy.sh',
|
||||||
|
'settings.ntfyUrl.hint': 'Enter your ntfy topic to receive push notifications. Leave server blank to use the default configured by your admin.',
|
||||||
|
'settings.ntfyUrl.tokenLabel': 'Access Token (optional)',
|
||||||
|
'settings.ntfyUrl.tokenHint': 'Required for password-protected topics.',
|
||||||
|
'settings.ntfyUrl.saved': 'Ntfy settings saved',
|
||||||
|
'settings.ntfyUrl.test': 'Test',
|
||||||
|
'settings.ntfyUrl.testSuccess': 'Test ntfy notification sent successfully',
|
||||||
|
'settings.ntfyUrl.testFailed': 'Test ntfy notification failed',
|
||||||
|
'settings.ntfyUrl.clearToken': 'Clear',
|
||||||
|
'settings.ntfyUrl.tokenCleared': 'Access token cleared',
|
||||||
'settings.notificationPreferences.inapp': 'In-App',
|
'settings.notificationPreferences.inapp': 'In-App',
|
||||||
'settings.notificationPreferences.webhook': 'Webhook',
|
'settings.notificationPreferences.webhook': 'Webhook',
|
||||||
'settings.notificationPreferences.email': 'Email',
|
'settings.notificationPreferences.email': 'Email',
|
||||||
|
'settings.notificationPreferences.ntfy': 'Ntfy',
|
||||||
'admin.notifications.emailPanel.title': 'Email (SMTP)',
|
'admin.notifications.emailPanel.title': 'Email (SMTP)',
|
||||||
'admin.notifications.webhookPanel.title': 'Webhook',
|
'admin.notifications.webhookPanel.title': 'Webhook',
|
||||||
'admin.notifications.inappPanel.title': 'In-App',
|
'admin.notifications.inappPanel.title': 'In-App',
|
||||||
@@ -1780,6 +1793,22 @@ const fr: Record<string, string> = {
|
|||||||
'admin.notifications.adminWebhookPanel.testSuccess': 'Webhook de test envoyé avec succès',
|
'admin.notifications.adminWebhookPanel.testSuccess': 'Webhook de test envoyé avec succès',
|
||||||
'admin.notifications.adminWebhookPanel.testFailed': 'Échec du webhook de test',
|
'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.adminWebhookPanel.alwaysOnHint': 'Le webhook admin s\'active automatiquement si une URL est configurée',
|
||||||
|
'admin.notifications.ntfy': 'Ntfy',
|
||||||
|
'admin.notifications.testNtfy': 'Send test ntfy',
|
||||||
|
'admin.notifications.testNtfySuccess': 'Test ntfy sent successfully',
|
||||||
|
'admin.notifications.testNtfyFailed': 'Test ntfy failed',
|
||||||
|
'admin.notifications.adminNtfyPanel.title': 'Admin Ntfy',
|
||||||
|
'admin.notifications.adminNtfyPanel.hint': 'This ntfy topic is used exclusively for admin notifications (e.g. version alerts). It is separate from per-user topics and always fires when configured.',
|
||||||
|
'admin.notifications.adminNtfyPanel.serverLabel': 'Ntfy Server URL',
|
||||||
|
'admin.notifications.adminNtfyPanel.serverPlaceholder': 'https://ntfy.sh',
|
||||||
|
'admin.notifications.adminNtfyPanel.topicLabel': 'Admin Topic',
|
||||||
|
'admin.notifications.adminNtfyPanel.topicPlaceholder': 'trek-admin-alerts',
|
||||||
|
'admin.notifications.adminNtfyPanel.tokenLabel': 'Access Token (optional)',
|
||||||
|
'admin.notifications.adminNtfyPanel.saved': 'Admin ntfy settings saved',
|
||||||
|
'admin.notifications.adminNtfyPanel.test': 'Send test ntfy',
|
||||||
|
'admin.notifications.adminNtfyPanel.testSuccess': 'Test ntfy sent successfully',
|
||||||
|
'admin.notifications.adminNtfyPanel.testFailed': 'Test ntfy failed',
|
||||||
|
'admin.notifications.adminNtfyPanel.alwaysOnHint': 'Admin ntfy always fires when a topic is configured',
|
||||||
'admin.notifications.adminNotificationsHint': '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.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',
|
'admin.tabs.notifications': 'Notifications',
|
||||||
'notifications.versionAvailable.title': 'Mise à jour disponible',
|
'notifications.versionAvailable.title': 'Mise à jour disponible',
|
||||||
|
|||||||
@@ -1760,14 +1760,27 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'settings.webhookUrl.label': 'Webhook URL',
|
'settings.webhookUrl.label': 'Webhook URL',
|
||||||
'settings.webhookUrl.placeholder': 'https://discord.com/api/webhooks/...',
|
'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.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.saved': 'Webhook URL mentve',
|
||||||
'settings.webhookUrl.test': 'Teszt',
|
'settings.webhookUrl.test': 'Teszt',
|
||||||
'settings.webhookUrl.testSuccess': 'Teszt webhook sikeresen elküldve',
|
'settings.webhookUrl.testSuccess': 'Teszt webhook sikeresen elküldve',
|
||||||
'settings.webhookUrl.testFailed': 'Teszt webhook sikertelen',
|
'settings.webhookUrl.testFailed': 'Teszt webhook sikertelen',
|
||||||
|
'settings.ntfyUrl.topicLabel': 'Ntfy Topic',
|
||||||
|
'settings.ntfyUrl.topicPlaceholder': 'my-trek-alerts',
|
||||||
|
'settings.ntfyUrl.serverLabel': 'Ntfy Server URL (optional)',
|
||||||
|
'settings.ntfyUrl.serverPlaceholder': 'https://ntfy.sh',
|
||||||
|
'settings.ntfyUrl.hint': 'Enter your ntfy topic to receive push notifications. Leave server blank to use the default configured by your admin.',
|
||||||
|
'settings.ntfyUrl.tokenLabel': 'Access Token (optional)',
|
||||||
|
'settings.ntfyUrl.tokenHint': 'Required for password-protected topics.',
|
||||||
|
'settings.ntfyUrl.saved': 'Ntfy settings saved',
|
||||||
|
'settings.ntfyUrl.test': 'Test',
|
||||||
|
'settings.ntfyUrl.testSuccess': 'Test ntfy notification sent successfully',
|
||||||
|
'settings.ntfyUrl.testFailed': 'Test ntfy notification failed',
|
||||||
|
'settings.ntfyUrl.clearToken': 'Clear',
|
||||||
|
'settings.ntfyUrl.tokenCleared': 'Access token cleared',
|
||||||
'settings.notificationPreferences.inapp': 'In-App',
|
'settings.notificationPreferences.inapp': 'In-App',
|
||||||
'settings.notificationPreferences.webhook': 'Webhook',
|
'settings.notificationPreferences.webhook': 'Webhook',
|
||||||
'settings.notificationPreferences.email': 'Email',
|
'settings.notificationPreferences.email': 'Email',
|
||||||
|
'settings.notificationPreferences.ntfy': 'Ntfy',
|
||||||
'admin.notifications.emailPanel.title': 'Email (SMTP)',
|
'admin.notifications.emailPanel.title': 'Email (SMTP)',
|
||||||
'admin.notifications.webhookPanel.title': 'Webhook',
|
'admin.notifications.webhookPanel.title': 'Webhook',
|
||||||
'admin.notifications.inappPanel.title': 'In-App',
|
'admin.notifications.inappPanel.title': 'In-App',
|
||||||
@@ -1778,6 +1791,22 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'admin.notifications.adminWebhookPanel.testSuccess': 'Teszt webhook sikeresen elküldve',
|
'admin.notifications.adminWebhookPanel.testSuccess': 'Teszt webhook sikeresen elküldve',
|
||||||
'admin.notifications.adminWebhookPanel.testFailed': 'Teszt webhook sikertelen',
|
'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.adminWebhookPanel.alwaysOnHint': 'Az admin webhook automatikusan küld, ha URL van beállítva',
|
||||||
|
'admin.notifications.ntfy': 'Ntfy',
|
||||||
|
'admin.notifications.testNtfy': 'Send test ntfy',
|
||||||
|
'admin.notifications.testNtfySuccess': 'Test ntfy sent successfully',
|
||||||
|
'admin.notifications.testNtfyFailed': 'Test ntfy failed',
|
||||||
|
'admin.notifications.adminNtfyPanel.title': 'Admin Ntfy',
|
||||||
|
'admin.notifications.adminNtfyPanel.hint': 'This ntfy topic is used exclusively for admin notifications (e.g. version alerts). It is separate from per-user topics and always fires when configured.',
|
||||||
|
'admin.notifications.adminNtfyPanel.serverLabel': 'Ntfy Server URL',
|
||||||
|
'admin.notifications.adminNtfyPanel.serverPlaceholder': 'https://ntfy.sh',
|
||||||
|
'admin.notifications.adminNtfyPanel.topicLabel': 'Admin Topic',
|
||||||
|
'admin.notifications.adminNtfyPanel.topicPlaceholder': 'trek-admin-alerts',
|
||||||
|
'admin.notifications.adminNtfyPanel.tokenLabel': 'Access Token (optional)',
|
||||||
|
'admin.notifications.adminNtfyPanel.saved': 'Admin ntfy settings saved',
|
||||||
|
'admin.notifications.adminNtfyPanel.test': 'Send test ntfy',
|
||||||
|
'admin.notifications.adminNtfyPanel.testSuccess': 'Test ntfy sent successfully',
|
||||||
|
'admin.notifications.adminNtfyPanel.testFailed': 'Test ntfy failed',
|
||||||
|
'admin.notifications.adminNtfyPanel.alwaysOnHint': 'Admin ntfy always fires when a topic is configured',
|
||||||
'admin.notifications.adminNotificationsHint': 'Á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.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',
|
'admin.tabs.notifications': 'Értesítések',
|
||||||
'notifications.versionAvailable.title': 'Elérhető frissítés',
|
'notifications.versionAvailable.title': 'Elérhető frissítés',
|
||||||
|
|||||||
@@ -189,15 +189,28 @@ const id: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'settings.notificationPreferences.email': 'Email',
|
'settings.notificationPreferences.email': 'Email',
|
||||||
'settings.notificationPreferences.webhook': 'Webhook',
|
'settings.notificationPreferences.webhook': 'Webhook',
|
||||||
'settings.notificationPreferences.inapp': 'In-App',
|
'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.notificationPreferences.noChannels': 'Belum ada saluran notifikasi yang dikonfigurasi. Minta admin untuk mengatur notifikasi email atau webhook.',
|
||||||
'settings.webhookUrl.label': 'Webhook URL',
|
'settings.webhookUrl.label': 'Webhook URL',
|
||||||
'settings.webhookUrl.placeholder': 'https://discord.com/api/webhooks/...',
|
'settings.webhookUrl.placeholder': 'https://discord.com/api/webhooks/...',
|
||||||
'settings.webhookUrl.hint': 'Masukkan URL webhook Discord, Slack, atau kustom untuk menerima notifikasi.',
|
'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.saved': 'Webhook URL tersimpan',
|
||||||
'settings.webhookUrl.test': 'Uji',
|
'settings.webhookUrl.test': 'Uji',
|
||||||
'settings.webhookUrl.testSuccess': 'Test webhook berhasil dikirim',
|
'settings.webhookUrl.testSuccess': 'Test webhook berhasil dikirim',
|
||||||
'settings.webhookUrl.testFailed': 'Test webhook gagal',
|
'settings.webhookUrl.testFailed': 'Test webhook gagal',
|
||||||
|
'settings.ntfyUrl.topicLabel': 'Ntfy Topic',
|
||||||
|
'settings.ntfyUrl.topicPlaceholder': 'my-trek-alerts',
|
||||||
|
'settings.ntfyUrl.serverLabel': 'Ntfy Server URL (optional)',
|
||||||
|
'settings.ntfyUrl.serverPlaceholder': 'https://ntfy.sh',
|
||||||
|
'settings.ntfyUrl.hint': 'Enter your ntfy topic to receive push notifications. Leave server blank to use the default configured by your admin.',
|
||||||
|
'settings.ntfyUrl.tokenLabel': 'Access Token (optional)',
|
||||||
|
'settings.ntfyUrl.tokenHint': 'Required for password-protected topics.',
|
||||||
|
'settings.ntfyUrl.saved': 'Ntfy settings saved',
|
||||||
|
'settings.ntfyUrl.test': 'Test',
|
||||||
|
'settings.ntfyUrl.testSuccess': 'Test ntfy notification sent successfully',
|
||||||
|
'settings.ntfyUrl.testFailed': 'Test ntfy notification failed',
|
||||||
|
'settings.ntfyUrl.clearToken': 'Clear',
|
||||||
|
'settings.ntfyUrl.tokenCleared': 'Access token cleared',
|
||||||
'admin.notifications.title': 'Notifikasi',
|
'admin.notifications.title': 'Notifikasi',
|
||||||
'admin.notifications.hint': 'Pilih satu saluran notifikasi. Hanya satu yang bisa aktif sekaligus.',
|
'admin.notifications.hint': 'Pilih satu saluran notifikasi. Hanya satu yang bisa aktif sekaligus.',
|
||||||
'admin.notifications.none': 'Dinonaktifkan',
|
'admin.notifications.none': 'Dinonaktifkan',
|
||||||
@@ -218,6 +231,22 @@ const id: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'admin.notifications.adminWebhookPanel.testSuccess': 'Test webhook berhasil dikirim',
|
'admin.notifications.adminWebhookPanel.testSuccess': 'Test webhook berhasil dikirim',
|
||||||
'admin.notifications.adminWebhookPanel.testFailed': 'Test webhook gagal',
|
'admin.notifications.adminWebhookPanel.testFailed': 'Test webhook gagal',
|
||||||
'admin.notifications.adminWebhookPanel.alwaysOnHint': 'Admin webhook selalu berjalan jika URL dikonfigurasi',
|
'admin.notifications.adminWebhookPanel.alwaysOnHint': 'Admin webhook selalu berjalan jika URL dikonfigurasi',
|
||||||
|
'admin.notifications.ntfy': 'Ntfy',
|
||||||
|
'admin.notifications.testNtfy': 'Send test ntfy',
|
||||||
|
'admin.notifications.testNtfySuccess': 'Test ntfy sent successfully',
|
||||||
|
'admin.notifications.testNtfyFailed': 'Test ntfy failed',
|
||||||
|
'admin.notifications.adminNtfyPanel.title': 'Admin Ntfy',
|
||||||
|
'admin.notifications.adminNtfyPanel.hint': 'This ntfy topic is used exclusively for admin notifications (e.g. version alerts). It is separate from per-user topics and always fires when configured.',
|
||||||
|
'admin.notifications.adminNtfyPanel.serverLabel': 'Ntfy Server URL',
|
||||||
|
'admin.notifications.adminNtfyPanel.serverPlaceholder': 'https://ntfy.sh',
|
||||||
|
'admin.notifications.adminNtfyPanel.topicLabel': 'Admin Topic',
|
||||||
|
'admin.notifications.adminNtfyPanel.topicPlaceholder': 'trek-admin-alerts',
|
||||||
|
'admin.notifications.adminNtfyPanel.tokenLabel': 'Access Token (optional)',
|
||||||
|
'admin.notifications.adminNtfyPanel.saved': 'Admin ntfy settings saved',
|
||||||
|
'admin.notifications.adminNtfyPanel.test': 'Send test ntfy',
|
||||||
|
'admin.notifications.adminNtfyPanel.testSuccess': 'Test ntfy sent successfully',
|
||||||
|
'admin.notifications.adminNtfyPanel.testFailed': 'Test ntfy failed',
|
||||||
|
'admin.notifications.adminNtfyPanel.alwaysOnHint': 'Admin ntfy always fires when a topic is configured',
|
||||||
'admin.notifications.adminNotificationsHint': 'Atur saluran mana yang mengirimkan notifikasi khusus admin (mis. peringatan versi).',
|
'admin.notifications.adminNotificationsHint': 'Atur saluran mana yang mengirimkan notifikasi khusus admin (mis. peringatan versi).',
|
||||||
'admin.smtp.title': 'Email & Notifikasi',
|
'admin.smtp.title': 'Email & Notifikasi',
|
||||||
'admin.smtp.hint': 'Konfigurasi SMTP untuk pengiriman notifikasi email.',
|
'admin.smtp.hint': 'Konfigurasi SMTP untuk pengiriman notifikasi email.',
|
||||||
|
|||||||
@@ -1763,14 +1763,27 @@ const it: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'settings.webhookUrl.label': 'URL webhook',
|
'settings.webhookUrl.label': 'URL webhook',
|
||||||
'settings.webhookUrl.placeholder': 'https://discord.com/api/webhooks/...',
|
'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.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.saved': 'URL webhook salvato',
|
||||||
'settings.webhookUrl.test': 'Test',
|
'settings.webhookUrl.test': 'Test',
|
||||||
'settings.webhookUrl.testSuccess': 'Webhook di test inviato con successo',
|
'settings.webhookUrl.testSuccess': 'Webhook di test inviato con successo',
|
||||||
'settings.webhookUrl.testFailed': 'Invio webhook di test fallito',
|
'settings.webhookUrl.testFailed': 'Invio webhook di test fallito',
|
||||||
|
'settings.ntfyUrl.topicLabel': 'Ntfy Topic',
|
||||||
|
'settings.ntfyUrl.topicPlaceholder': 'my-trek-alerts',
|
||||||
|
'settings.ntfyUrl.serverLabel': 'Ntfy Server URL (optional)',
|
||||||
|
'settings.ntfyUrl.serverPlaceholder': 'https://ntfy.sh',
|
||||||
|
'settings.ntfyUrl.hint': 'Enter your ntfy topic to receive push notifications. Leave server blank to use the default configured by your admin.',
|
||||||
|
'settings.ntfyUrl.tokenLabel': 'Access Token (optional)',
|
||||||
|
'settings.ntfyUrl.tokenHint': 'Required for password-protected topics.',
|
||||||
|
'settings.ntfyUrl.saved': 'Ntfy settings saved',
|
||||||
|
'settings.ntfyUrl.test': 'Test',
|
||||||
|
'settings.ntfyUrl.testSuccess': 'Test ntfy notification sent successfully',
|
||||||
|
'settings.ntfyUrl.testFailed': 'Test ntfy notification failed',
|
||||||
|
'settings.ntfyUrl.clearToken': 'Clear',
|
||||||
|
'settings.ntfyUrl.tokenCleared': 'Access token cleared',
|
||||||
'settings.notificationPreferences.inapp': 'In-App',
|
'settings.notificationPreferences.inapp': 'In-App',
|
||||||
'settings.notificationPreferences.webhook': 'Webhook',
|
'settings.notificationPreferences.webhook': 'Webhook',
|
||||||
'settings.notificationPreferences.email': 'Email',
|
'settings.notificationPreferences.email': 'Email',
|
||||||
|
'settings.notificationPreferences.ntfy': 'Ntfy',
|
||||||
'admin.notifications.emailPanel.title': 'Email (SMTP)',
|
'admin.notifications.emailPanel.title': 'Email (SMTP)',
|
||||||
'admin.notifications.webhookPanel.title': 'Webhook',
|
'admin.notifications.webhookPanel.title': 'Webhook',
|
||||||
'admin.notifications.inappPanel.title': 'In-App',
|
'admin.notifications.inappPanel.title': 'In-App',
|
||||||
@@ -1781,6 +1794,22 @@ const it: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'admin.notifications.adminWebhookPanel.testSuccess': 'Webhook di test inviato con successo',
|
'admin.notifications.adminWebhookPanel.testSuccess': 'Webhook di test inviato con successo',
|
||||||
'admin.notifications.adminWebhookPanel.testFailed': 'Invio webhook di test fallito',
|
'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.adminWebhookPanel.alwaysOnHint': 'Il webhook admin si attiva automaticamente quando è configurato un URL',
|
||||||
|
'admin.notifications.ntfy': 'Ntfy',
|
||||||
|
'admin.notifications.testNtfy': 'Send test ntfy',
|
||||||
|
'admin.notifications.testNtfySuccess': 'Test ntfy sent successfully',
|
||||||
|
'admin.notifications.testNtfyFailed': 'Test ntfy failed',
|
||||||
|
'admin.notifications.adminNtfyPanel.title': 'Admin Ntfy',
|
||||||
|
'admin.notifications.adminNtfyPanel.hint': 'This ntfy topic is used exclusively for admin notifications (e.g. version alerts). It is separate from per-user topics and always fires when configured.',
|
||||||
|
'admin.notifications.adminNtfyPanel.serverLabel': 'Ntfy Server URL',
|
||||||
|
'admin.notifications.adminNtfyPanel.serverPlaceholder': 'https://ntfy.sh',
|
||||||
|
'admin.notifications.adminNtfyPanel.topicLabel': 'Admin Topic',
|
||||||
|
'admin.notifications.adminNtfyPanel.topicPlaceholder': 'trek-admin-alerts',
|
||||||
|
'admin.notifications.adminNtfyPanel.tokenLabel': 'Access Token (optional)',
|
||||||
|
'admin.notifications.adminNtfyPanel.saved': 'Admin ntfy settings saved',
|
||||||
|
'admin.notifications.adminNtfyPanel.test': 'Send test ntfy',
|
||||||
|
'admin.notifications.adminNtfyPanel.testSuccess': 'Test ntfy sent successfully',
|
||||||
|
'admin.notifications.adminNtfyPanel.testFailed': 'Test ntfy failed',
|
||||||
|
'admin.notifications.adminNtfyPanel.alwaysOnHint': 'Admin ntfy always fires when a topic is configured',
|
||||||
'admin.notifications.adminNotificationsHint': 'Configura quali canali consegnano le notifiche admin (es. avvisi di versione). Il webhook si attiva automaticamente se è impostato un URL webhook admin.',
|
'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',
|
'admin.tabs.notifications': 'Notifiche',
|
||||||
'notifications.versionAvailable.title': 'Aggiornamento disponibile',
|
'notifications.versionAvailable.title': 'Aggiornamento disponibile',
|
||||||
|
|||||||
@@ -1762,14 +1762,27 @@ const nl: Record<string, string> = {
|
|||||||
'settings.webhookUrl.label': 'Webhook-URL',
|
'settings.webhookUrl.label': 'Webhook-URL',
|
||||||
'settings.webhookUrl.placeholder': 'https://discord.com/api/webhooks/...',
|
'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.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.saved': 'Webhook-URL opgeslagen',
|
||||||
'settings.webhookUrl.test': 'Testen',
|
'settings.webhookUrl.test': 'Testen',
|
||||||
'settings.webhookUrl.testSuccess': 'Test-webhook succesvol verzonden',
|
'settings.webhookUrl.testSuccess': 'Test-webhook succesvol verzonden',
|
||||||
'settings.webhookUrl.testFailed': 'Test-webhook mislukt',
|
'settings.webhookUrl.testFailed': 'Test-webhook mislukt',
|
||||||
|
'settings.ntfyUrl.topicLabel': 'Ntfy Topic',
|
||||||
|
'settings.ntfyUrl.topicPlaceholder': 'my-trek-alerts',
|
||||||
|
'settings.ntfyUrl.serverLabel': 'Ntfy Server URL (optional)',
|
||||||
|
'settings.ntfyUrl.serverPlaceholder': 'https://ntfy.sh',
|
||||||
|
'settings.ntfyUrl.hint': 'Enter your ntfy topic to receive push notifications. Leave server blank to use the default configured by your admin.',
|
||||||
|
'settings.ntfyUrl.tokenLabel': 'Access Token (optional)',
|
||||||
|
'settings.ntfyUrl.tokenHint': 'Required for password-protected topics.',
|
||||||
|
'settings.ntfyUrl.saved': 'Ntfy settings saved',
|
||||||
|
'settings.ntfyUrl.test': 'Test',
|
||||||
|
'settings.ntfyUrl.testSuccess': 'Test ntfy notification sent successfully',
|
||||||
|
'settings.ntfyUrl.testFailed': 'Test ntfy notification failed',
|
||||||
|
'settings.ntfyUrl.clearToken': 'Clear',
|
||||||
|
'settings.ntfyUrl.tokenCleared': 'Access token cleared',
|
||||||
'settings.notificationPreferences.inapp': 'In-App',
|
'settings.notificationPreferences.inapp': 'In-App',
|
||||||
'settings.notificationPreferences.webhook': 'Webhook',
|
'settings.notificationPreferences.webhook': 'Webhook',
|
||||||
'settings.notificationPreferences.email': 'Email',
|
'settings.notificationPreferences.email': 'Email',
|
||||||
|
'settings.notificationPreferences.ntfy': 'Ntfy',
|
||||||
'admin.notifications.emailPanel.title': 'Email (SMTP)',
|
'admin.notifications.emailPanel.title': 'Email (SMTP)',
|
||||||
'admin.notifications.webhookPanel.title': 'Webhook',
|
'admin.notifications.webhookPanel.title': 'Webhook',
|
||||||
'admin.notifications.inappPanel.title': 'In-App',
|
'admin.notifications.inappPanel.title': 'In-App',
|
||||||
@@ -1780,6 +1793,22 @@ const nl: Record<string, string> = {
|
|||||||
'admin.notifications.adminWebhookPanel.testSuccess': 'Test-webhook succesvol verzonden',
|
'admin.notifications.adminWebhookPanel.testSuccess': 'Test-webhook succesvol verzonden',
|
||||||
'admin.notifications.adminWebhookPanel.testFailed': 'Test-webhook mislukt',
|
'admin.notifications.adminWebhookPanel.testFailed': 'Test-webhook mislukt',
|
||||||
'admin.notifications.adminWebhookPanel.alwaysOnHint': 'Admin-webhook verstuurt automatisch als er een URL is ingesteld',
|
'admin.notifications.adminWebhookPanel.alwaysOnHint': 'Admin-webhook verstuurt automatisch als er een URL is ingesteld',
|
||||||
|
'admin.notifications.ntfy': 'Ntfy',
|
||||||
|
'admin.notifications.testNtfy': 'Send test ntfy',
|
||||||
|
'admin.notifications.testNtfySuccess': 'Test ntfy sent successfully',
|
||||||
|
'admin.notifications.testNtfyFailed': 'Test ntfy failed',
|
||||||
|
'admin.notifications.adminNtfyPanel.title': 'Admin Ntfy',
|
||||||
|
'admin.notifications.adminNtfyPanel.hint': 'This ntfy topic is used exclusively for admin notifications (e.g. version alerts). It is separate from per-user topics and always fires when configured.',
|
||||||
|
'admin.notifications.adminNtfyPanel.serverLabel': 'Ntfy Server URL',
|
||||||
|
'admin.notifications.adminNtfyPanel.serverPlaceholder': 'https://ntfy.sh',
|
||||||
|
'admin.notifications.adminNtfyPanel.topicLabel': 'Admin Topic',
|
||||||
|
'admin.notifications.adminNtfyPanel.topicPlaceholder': 'trek-admin-alerts',
|
||||||
|
'admin.notifications.adminNtfyPanel.tokenLabel': 'Access Token (optional)',
|
||||||
|
'admin.notifications.adminNtfyPanel.saved': 'Admin ntfy settings saved',
|
||||||
|
'admin.notifications.adminNtfyPanel.test': 'Send test ntfy',
|
||||||
|
'admin.notifications.adminNtfyPanel.testSuccess': 'Test ntfy sent successfully',
|
||||||
|
'admin.notifications.adminNtfyPanel.testFailed': 'Test ntfy failed',
|
||||||
|
'admin.notifications.adminNtfyPanel.alwaysOnHint': 'Admin ntfy always fires when a topic is configured',
|
||||||
'admin.notifications.adminNotificationsHint': '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.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',
|
'admin.tabs.notifications': 'Meldingen',
|
||||||
'notifications.versionAvailable.title': 'Update beschikbaar',
|
'notifications.versionAvailable.title': 'Update beschikbaar',
|
||||||
|
|||||||
@@ -1596,6 +1596,22 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'admin.notifications.adminWebhookPanel.testSuccess': 'Testowy webhook wysłany pomyślnie',
|
'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.testFailed': 'Wysyłanie testowego webhooka nie powiodło się',
|
||||||
'admin.notifications.adminWebhookPanel.alwaysOnHint': 'Webhook admina wysyła automatycznie, gdy URL jest skonfigurowany',
|
'admin.notifications.adminWebhookPanel.alwaysOnHint': 'Webhook admina wysyła automatycznie, gdy URL jest skonfigurowany',
|
||||||
|
'admin.notifications.ntfy': 'Ntfy',
|
||||||
|
'admin.notifications.testNtfy': 'Send test ntfy',
|
||||||
|
'admin.notifications.testNtfySuccess': 'Test ntfy sent successfully',
|
||||||
|
'admin.notifications.testNtfyFailed': 'Test ntfy failed',
|
||||||
|
'admin.notifications.adminNtfyPanel.title': 'Admin Ntfy',
|
||||||
|
'admin.notifications.adminNtfyPanel.hint': 'This ntfy topic is used exclusively for admin notifications (e.g. version alerts). It is separate from per-user topics and always fires when configured.',
|
||||||
|
'admin.notifications.adminNtfyPanel.serverLabel': 'Ntfy Server URL',
|
||||||
|
'admin.notifications.adminNtfyPanel.serverPlaceholder': 'https://ntfy.sh',
|
||||||
|
'admin.notifications.adminNtfyPanel.topicLabel': 'Admin Topic',
|
||||||
|
'admin.notifications.adminNtfyPanel.topicPlaceholder': 'trek-admin-alerts',
|
||||||
|
'admin.notifications.adminNtfyPanel.tokenLabel': 'Access Token (optional)',
|
||||||
|
'admin.notifications.adminNtfyPanel.saved': 'Admin ntfy settings saved',
|
||||||
|
'admin.notifications.adminNtfyPanel.test': 'Send test ntfy',
|
||||||
|
'admin.notifications.adminNtfyPanel.testSuccess': 'Test ntfy sent successfully',
|
||||||
|
'admin.notifications.adminNtfyPanel.testFailed': 'Test ntfy failed',
|
||||||
|
'admin.notifications.adminNtfyPanel.alwaysOnHint': 'Admin ntfy always fires when a topic is configured',
|
||||||
'admin.notifications.adminNotificationsHint': 'Skonfiguruj, które kanały dostarczają powiadomienia admina (np. alerty o wersjach). Webhook wysyła automatycznie, gdy ustawiony jest URL webhooka admina.',
|
'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.).',
|
'admin.webhook.hint': 'Pozwól użytkownikom konfigurować własne adresy URL webhooka dla powiadomień (Discord, Slack itp.).',
|
||||||
'settings.notificationsDisabled': 'Powiadomienia nie są skonfigurowane.',
|
'settings.notificationsDisabled': 'Powiadomienia nie są skonfigurowane.',
|
||||||
@@ -1603,14 +1619,27 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'settings.webhookUrl.label': 'URL webhooka',
|
'settings.webhookUrl.label': 'URL webhooka',
|
||||||
'settings.webhookUrl.placeholder': 'https://discord.com/api/webhooks/...',
|
'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.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.saved': 'URL webhooka zapisany',
|
||||||
'settings.webhookUrl.test': 'Testuj',
|
'settings.webhookUrl.test': 'Testuj',
|
||||||
'settings.webhookUrl.testSuccess': 'Testowy webhook wysłany pomyślnie',
|
'settings.webhookUrl.testSuccess': 'Testowy webhook wysłany pomyślnie',
|
||||||
'settings.webhookUrl.testFailed': 'Wysyłanie testowego webhooka nie powiodło się',
|
'settings.webhookUrl.testFailed': 'Wysyłanie testowego webhooka nie powiodło się',
|
||||||
|
'settings.ntfyUrl.topicLabel': 'Ntfy Topic',
|
||||||
|
'settings.ntfyUrl.topicPlaceholder': 'my-trek-alerts',
|
||||||
|
'settings.ntfyUrl.serverLabel': 'Ntfy Server URL (optional)',
|
||||||
|
'settings.ntfyUrl.serverPlaceholder': 'https://ntfy.sh',
|
||||||
|
'settings.ntfyUrl.hint': 'Enter your ntfy topic to receive push notifications. Leave server blank to use the default configured by your admin.',
|
||||||
|
'settings.ntfyUrl.tokenLabel': 'Access Token (optional)',
|
||||||
|
'settings.ntfyUrl.tokenHint': 'Required for password-protected topics.',
|
||||||
|
'settings.ntfyUrl.saved': 'Ntfy settings saved',
|
||||||
|
'settings.ntfyUrl.test': 'Test',
|
||||||
|
'settings.ntfyUrl.testSuccess': 'Test ntfy notification sent successfully',
|
||||||
|
'settings.ntfyUrl.testFailed': 'Test ntfy notification failed',
|
||||||
|
'settings.ntfyUrl.clearToken': 'Clear',
|
||||||
|
'settings.ntfyUrl.tokenCleared': 'Access token cleared',
|
||||||
'settings.notificationPreferences.inapp': 'In-App',
|
'settings.notificationPreferences.inapp': 'In-App',
|
||||||
'settings.notificationPreferences.webhook': 'Webhook',
|
'settings.notificationPreferences.webhook': 'Webhook',
|
||||||
'settings.notificationPreferences.email': 'Email',
|
'settings.notificationPreferences.email': 'Email',
|
||||||
|
'settings.notificationPreferences.ntfy': 'Ntfy',
|
||||||
'settings.notificationsActive': 'Aktywny kanał',
|
'settings.notificationsActive': 'Aktywny kanał',
|
||||||
'settings.notificationsManagedByAdmin': 'Zdarzenia konfigurowane przez administratora.',
|
'settings.notificationsManagedByAdmin': 'Zdarzenia konfigurowane przez administratora.',
|
||||||
'settings.mustChangePassword': 'Musisz zmienić hasło przed kontynuowaniem.',
|
'settings.mustChangePassword': 'Musisz zmienić hasło przed kontynuowaniem.',
|
||||||
|
|||||||
@@ -1759,14 +1759,27 @@ const ru: Record<string, string> = {
|
|||||||
'settings.webhookUrl.label': 'URL вебхука',
|
'settings.webhookUrl.label': 'URL вебхука',
|
||||||
'settings.webhookUrl.placeholder': 'https://discord.com/api/webhooks/...',
|
'settings.webhookUrl.placeholder': 'https://discord.com/api/webhooks/...',
|
||||||
'settings.webhookUrl.hint': 'Введите URL вашего вебхука Discord, Slack или пользовательского для получения уведомлений.',
|
'settings.webhookUrl.hint': 'Введите URL вашего вебхука Discord, Slack или пользовательского для получения уведомлений.',
|
||||||
'settings.webhookUrl.save': 'Сохранить',
|
|
||||||
'settings.webhookUrl.saved': 'URL вебхука сохранён',
|
'settings.webhookUrl.saved': 'URL вебхука сохранён',
|
||||||
'settings.webhookUrl.test': 'Тест',
|
'settings.webhookUrl.test': 'Тест',
|
||||||
'settings.webhookUrl.testSuccess': 'Тестовый вебхук успешно отправлен',
|
'settings.webhookUrl.testSuccess': 'Тестовый вебхук успешно отправлен',
|
||||||
'settings.webhookUrl.testFailed': 'Ошибка тестового вебхука',
|
'settings.webhookUrl.testFailed': 'Ошибка тестового вебхука',
|
||||||
|
'settings.ntfyUrl.topicLabel': 'Ntfy Topic',
|
||||||
|
'settings.ntfyUrl.topicPlaceholder': 'my-trek-alerts',
|
||||||
|
'settings.ntfyUrl.serverLabel': 'Ntfy Server URL (optional)',
|
||||||
|
'settings.ntfyUrl.serverPlaceholder': 'https://ntfy.sh',
|
||||||
|
'settings.ntfyUrl.hint': 'Enter your ntfy topic to receive push notifications. Leave server blank to use the default configured by your admin.',
|
||||||
|
'settings.ntfyUrl.tokenLabel': 'Access Token (optional)',
|
||||||
|
'settings.ntfyUrl.tokenHint': 'Required for password-protected topics.',
|
||||||
|
'settings.ntfyUrl.saved': 'Ntfy settings saved',
|
||||||
|
'settings.ntfyUrl.test': 'Test',
|
||||||
|
'settings.ntfyUrl.testSuccess': 'Test ntfy notification sent successfully',
|
||||||
|
'settings.ntfyUrl.testFailed': 'Test ntfy notification failed',
|
||||||
|
'settings.ntfyUrl.clearToken': 'Clear',
|
||||||
|
'settings.ntfyUrl.tokenCleared': 'Access token cleared',
|
||||||
'settings.notificationPreferences.inapp': 'In-App',
|
'settings.notificationPreferences.inapp': 'In-App',
|
||||||
'settings.notificationPreferences.webhook': 'Webhook',
|
'settings.notificationPreferences.webhook': 'Webhook',
|
||||||
'settings.notificationPreferences.email': 'Email',
|
'settings.notificationPreferences.email': 'Email',
|
||||||
|
'settings.notificationPreferences.ntfy': 'Ntfy',
|
||||||
'admin.notifications.emailPanel.title': 'Email (SMTP)',
|
'admin.notifications.emailPanel.title': 'Email (SMTP)',
|
||||||
'admin.notifications.webhookPanel.title': 'Webhook',
|
'admin.notifications.webhookPanel.title': 'Webhook',
|
||||||
'admin.notifications.inappPanel.title': 'In-App',
|
'admin.notifications.inappPanel.title': 'In-App',
|
||||||
@@ -1777,6 +1790,22 @@ const ru: Record<string, string> = {
|
|||||||
'admin.notifications.adminWebhookPanel.testSuccess': 'Тестовый вебхук успешно отправлен',
|
'admin.notifications.adminWebhookPanel.testSuccess': 'Тестовый вебхук успешно отправлен',
|
||||||
'admin.notifications.adminWebhookPanel.testFailed': 'Ошибка тестового вебхука',
|
'admin.notifications.adminWebhookPanel.testFailed': 'Ошибка тестового вебхука',
|
||||||
'admin.notifications.adminWebhookPanel.alwaysOnHint': 'Вебхук администратора отправляется автоматически при наличии URL',
|
'admin.notifications.adminWebhookPanel.alwaysOnHint': 'Вебхук администратора отправляется автоматически при наличии URL',
|
||||||
|
'admin.notifications.ntfy': 'Ntfy',
|
||||||
|
'admin.notifications.testNtfy': 'Send test ntfy',
|
||||||
|
'admin.notifications.testNtfySuccess': 'Test ntfy sent successfully',
|
||||||
|
'admin.notifications.testNtfyFailed': 'Test ntfy failed',
|
||||||
|
'admin.notifications.adminNtfyPanel.title': 'Admin Ntfy',
|
||||||
|
'admin.notifications.adminNtfyPanel.hint': 'This ntfy topic is used exclusively for admin notifications (e.g. version alerts). It is separate from per-user topics and always fires when configured.',
|
||||||
|
'admin.notifications.adminNtfyPanel.serverLabel': 'Ntfy Server URL',
|
||||||
|
'admin.notifications.adminNtfyPanel.serverPlaceholder': 'https://ntfy.sh',
|
||||||
|
'admin.notifications.adminNtfyPanel.topicLabel': 'Admin Topic',
|
||||||
|
'admin.notifications.adminNtfyPanel.topicPlaceholder': 'trek-admin-alerts',
|
||||||
|
'admin.notifications.adminNtfyPanel.tokenLabel': 'Access Token (optional)',
|
||||||
|
'admin.notifications.adminNtfyPanel.saved': 'Admin ntfy settings saved',
|
||||||
|
'admin.notifications.adminNtfyPanel.test': 'Send test ntfy',
|
||||||
|
'admin.notifications.adminNtfyPanel.testSuccess': 'Test ntfy sent successfully',
|
||||||
|
'admin.notifications.adminNtfyPanel.testFailed': 'Test ntfy failed',
|
||||||
|
'admin.notifications.adminNtfyPanel.alwaysOnHint': 'Admin ntfy always fires when a topic is configured',
|
||||||
'admin.notifications.adminNotificationsHint': 'Настройте, какие каналы доставляют уведомления администратора (например, оповещения о версиях). Вебхук отправляется автоматически, если задан URL вебхука администратора.',
|
'admin.notifications.adminNotificationsHint': 'Настройте, какие каналы доставляют уведомления администратора (например, оповещения о версиях). Вебхук отправляется автоматически, если задан URL вебхука администратора.',
|
||||||
'admin.tabs.notifications': 'Уведомления',
|
'admin.tabs.notifications': 'Уведомления',
|
||||||
'notifications.versionAvailable.title': 'Доступно обновление',
|
'notifications.versionAvailable.title': 'Доступно обновление',
|
||||||
|
|||||||
@@ -1759,14 +1759,27 @@ const zh: Record<string, string> = {
|
|||||||
'settings.webhookUrl.label': 'Webhook URL',
|
'settings.webhookUrl.label': 'Webhook URL',
|
||||||
'settings.webhookUrl.placeholder': 'https://discord.com/api/webhooks/...',
|
'settings.webhookUrl.placeholder': 'https://discord.com/api/webhooks/...',
|
||||||
'settings.webhookUrl.hint': '输入您的 Discord、Slack 或自定义 Webhook URL 以接收通知。',
|
'settings.webhookUrl.hint': '输入您的 Discord、Slack 或自定义 Webhook URL 以接收通知。',
|
||||||
'settings.webhookUrl.save': '保存',
|
|
||||||
'settings.webhookUrl.saved': 'Webhook URL 已保存',
|
'settings.webhookUrl.saved': 'Webhook URL 已保存',
|
||||||
'settings.webhookUrl.test': '测试',
|
'settings.webhookUrl.test': '测试',
|
||||||
'settings.webhookUrl.testSuccess': '测试 Webhook 发送成功',
|
'settings.webhookUrl.testSuccess': '测试 Webhook 发送成功',
|
||||||
'settings.webhookUrl.testFailed': '测试 Webhook 失败',
|
'settings.webhookUrl.testFailed': '测试 Webhook 失败',
|
||||||
|
'settings.ntfyUrl.topicLabel': 'Ntfy Topic',
|
||||||
|
'settings.ntfyUrl.topicPlaceholder': 'my-trek-alerts',
|
||||||
|
'settings.ntfyUrl.serverLabel': 'Ntfy Server URL (optional)',
|
||||||
|
'settings.ntfyUrl.serverPlaceholder': 'https://ntfy.sh',
|
||||||
|
'settings.ntfyUrl.hint': 'Enter your ntfy topic to receive push notifications. Leave server blank to use the default configured by your admin.',
|
||||||
|
'settings.ntfyUrl.tokenLabel': 'Access Token (optional)',
|
||||||
|
'settings.ntfyUrl.tokenHint': 'Required for password-protected topics.',
|
||||||
|
'settings.ntfyUrl.saved': 'Ntfy settings saved',
|
||||||
|
'settings.ntfyUrl.test': 'Test',
|
||||||
|
'settings.ntfyUrl.testSuccess': 'Test ntfy notification sent successfully',
|
||||||
|
'settings.ntfyUrl.testFailed': 'Test ntfy notification failed',
|
||||||
|
'settings.ntfyUrl.clearToken': 'Clear',
|
||||||
|
'settings.ntfyUrl.tokenCleared': 'Access token cleared',
|
||||||
'settings.notificationPreferences.inapp': 'In-App',
|
'settings.notificationPreferences.inapp': 'In-App',
|
||||||
'settings.notificationPreferences.webhook': 'Webhook',
|
'settings.notificationPreferences.webhook': 'Webhook',
|
||||||
'settings.notificationPreferences.email': 'Email',
|
'settings.notificationPreferences.email': 'Email',
|
||||||
|
'settings.notificationPreferences.ntfy': 'Ntfy',
|
||||||
'admin.notifications.emailPanel.title': 'Email (SMTP)',
|
'admin.notifications.emailPanel.title': 'Email (SMTP)',
|
||||||
'admin.notifications.webhookPanel.title': 'Webhook',
|
'admin.notifications.webhookPanel.title': 'Webhook',
|
||||||
'admin.notifications.inappPanel.title': 'In-App',
|
'admin.notifications.inappPanel.title': 'In-App',
|
||||||
@@ -1777,6 +1790,22 @@ const zh: Record<string, string> = {
|
|||||||
'admin.notifications.adminWebhookPanel.testSuccess': '测试 Webhook 发送成功',
|
'admin.notifications.adminWebhookPanel.testSuccess': '测试 Webhook 发送成功',
|
||||||
'admin.notifications.adminWebhookPanel.testFailed': '测试 Webhook 失败',
|
'admin.notifications.adminWebhookPanel.testFailed': '测试 Webhook 失败',
|
||||||
'admin.notifications.adminWebhookPanel.alwaysOnHint': '配置 URL 后管理员 Webhook 自动触发',
|
'admin.notifications.adminWebhookPanel.alwaysOnHint': '配置 URL 后管理员 Webhook 自动触发',
|
||||||
|
'admin.notifications.ntfy': 'Ntfy',
|
||||||
|
'admin.notifications.testNtfy': 'Send test ntfy',
|
||||||
|
'admin.notifications.testNtfySuccess': 'Test ntfy sent successfully',
|
||||||
|
'admin.notifications.testNtfyFailed': 'Test ntfy failed',
|
||||||
|
'admin.notifications.adminNtfyPanel.title': 'Admin Ntfy',
|
||||||
|
'admin.notifications.adminNtfyPanel.hint': 'This ntfy topic is used exclusively for admin notifications (e.g. version alerts). It is separate from per-user topics and always fires when configured.',
|
||||||
|
'admin.notifications.adminNtfyPanel.serverLabel': 'Ntfy Server URL',
|
||||||
|
'admin.notifications.adminNtfyPanel.serverPlaceholder': 'https://ntfy.sh',
|
||||||
|
'admin.notifications.adminNtfyPanel.topicLabel': 'Admin Topic',
|
||||||
|
'admin.notifications.adminNtfyPanel.topicPlaceholder': 'trek-admin-alerts',
|
||||||
|
'admin.notifications.adminNtfyPanel.tokenLabel': 'Access Token (optional)',
|
||||||
|
'admin.notifications.adminNtfyPanel.saved': 'Admin ntfy settings saved',
|
||||||
|
'admin.notifications.adminNtfyPanel.test': 'Send test ntfy',
|
||||||
|
'admin.notifications.adminNtfyPanel.testSuccess': 'Test ntfy sent successfully',
|
||||||
|
'admin.notifications.adminNtfyPanel.testFailed': 'Test ntfy failed',
|
||||||
|
'admin.notifications.adminNtfyPanel.alwaysOnHint': 'Admin ntfy always fires when a topic is configured',
|
||||||
'admin.notifications.adminNotificationsHint': '配置哪些渠道发送管理员通知(如版本更新提醒)。设置管理员 Webhook URL 后,Webhook 将自动触发。',
|
'admin.notifications.adminNotificationsHint': '配置哪些渠道发送管理员通知(如版本更新提醒)。设置管理员 Webhook URL 后,Webhook 将自动触发。',
|
||||||
'admin.tabs.notifications': '通知',
|
'admin.tabs.notifications': '通知',
|
||||||
'notifications.versionAvailable.title': '有可用更新',
|
'notifications.versionAvailable.title': '有可用更新',
|
||||||
|
|||||||
@@ -186,15 +186,28 @@ const zhTw: Record<string, string> = {
|
|||||||
'settings.notificationPreferences.email': '電子郵件',
|
'settings.notificationPreferences.email': '電子郵件',
|
||||||
'settings.notificationPreferences.webhook': 'Webhook',
|
'settings.notificationPreferences.webhook': 'Webhook',
|
||||||
'settings.notificationPreferences.inapp': '應用程式內',
|
'settings.notificationPreferences.inapp': '應用程式內',
|
||||||
|
'settings.notificationPreferences.ntfy': 'Ntfy',
|
||||||
'settings.notificationPreferences.noChannels': '未配置通知渠道。請聯絡管理員設定電子郵件或 Webhook 通知。',
|
'settings.notificationPreferences.noChannels': '未配置通知渠道。請聯絡管理員設定電子郵件或 Webhook 通知。',
|
||||||
'settings.webhookUrl.label': 'Webhook URL',
|
'settings.webhookUrl.label': 'Webhook URL',
|
||||||
'settings.webhookUrl.placeholder': 'https://discord.com/api/webhooks/...',
|
'settings.webhookUrl.placeholder': 'https://discord.com/api/webhooks/...',
|
||||||
'settings.webhookUrl.hint': '輸入您的 Discord、Slack 或自訂 Webhook URL 以接收通知。',
|
'settings.webhookUrl.hint': '輸入您的 Discord、Slack 或自訂 Webhook URL 以接收通知。',
|
||||||
'settings.webhookUrl.save': '儲存',
|
|
||||||
'settings.webhookUrl.saved': 'Webhook URL 已儲存',
|
'settings.webhookUrl.saved': 'Webhook URL 已儲存',
|
||||||
'settings.webhookUrl.test': '測試',
|
'settings.webhookUrl.test': '測試',
|
||||||
'settings.webhookUrl.testSuccess': '測試 Webhook 傳送成功',
|
'settings.webhookUrl.testSuccess': '測試 Webhook 傳送成功',
|
||||||
'settings.webhookUrl.testFailed': '測試 Webhook 傳送失敗',
|
'settings.webhookUrl.testFailed': '測試 Webhook 傳送失敗',
|
||||||
|
'settings.ntfyUrl.topicLabel': 'Ntfy Topic',
|
||||||
|
'settings.ntfyUrl.topicPlaceholder': 'my-trek-alerts',
|
||||||
|
'settings.ntfyUrl.serverLabel': 'Ntfy Server URL (optional)',
|
||||||
|
'settings.ntfyUrl.serverPlaceholder': 'https://ntfy.sh',
|
||||||
|
'settings.ntfyUrl.hint': 'Enter your ntfy topic to receive push notifications. Leave server blank to use the default configured by your admin.',
|
||||||
|
'settings.ntfyUrl.tokenLabel': 'Access Token (optional)',
|
||||||
|
'settings.ntfyUrl.tokenHint': 'Required for password-protected topics.',
|
||||||
|
'settings.ntfyUrl.saved': 'Ntfy settings saved',
|
||||||
|
'settings.ntfyUrl.test': 'Test',
|
||||||
|
'settings.ntfyUrl.testSuccess': 'Test ntfy notification sent successfully',
|
||||||
|
'settings.ntfyUrl.testFailed': 'Test ntfy notification failed',
|
||||||
|
'settings.ntfyUrl.clearToken': 'Clear',
|
||||||
|
'settings.ntfyUrl.tokenCleared': 'Access token cleared',
|
||||||
'settings.notificationsDisabled': '通知尚未配置。請聯絡管理員啟用電子郵件或 Webhook 通知。',
|
'settings.notificationsDisabled': '通知尚未配置。請聯絡管理員啟用電子郵件或 Webhook 通知。',
|
||||||
'settings.notificationsActive': '活躍頻道',
|
'settings.notificationsActive': '活躍頻道',
|
||||||
'settings.notificationsManagedByAdmin': '通知事件由管理員配置。',
|
'settings.notificationsManagedByAdmin': '通知事件由管理員配置。',
|
||||||
@@ -218,6 +231,22 @@ const zhTw: Record<string, string> = {
|
|||||||
'admin.notifications.adminWebhookPanel.testSuccess': '測試 Webhook 傳送成功',
|
'admin.notifications.adminWebhookPanel.testSuccess': '測試 Webhook 傳送成功',
|
||||||
'admin.notifications.adminWebhookPanel.testFailed': '測試 Webhook 傳送失敗',
|
'admin.notifications.adminWebhookPanel.testFailed': '測試 Webhook 傳送失敗',
|
||||||
'admin.notifications.adminWebhookPanel.alwaysOnHint': '配置 URL 後,管理員 Webhook 始終觸發',
|
'admin.notifications.adminWebhookPanel.alwaysOnHint': '配置 URL 後,管理員 Webhook 始終觸發',
|
||||||
|
'admin.notifications.ntfy': 'Ntfy',
|
||||||
|
'admin.notifications.testNtfy': 'Send test ntfy',
|
||||||
|
'admin.notifications.testNtfySuccess': 'Test ntfy sent successfully',
|
||||||
|
'admin.notifications.testNtfyFailed': 'Test ntfy failed',
|
||||||
|
'admin.notifications.adminNtfyPanel.title': 'Admin Ntfy',
|
||||||
|
'admin.notifications.adminNtfyPanel.hint': 'This ntfy topic is used exclusively for admin notifications (e.g. version alerts). It is separate from per-user topics and always fires when configured.',
|
||||||
|
'admin.notifications.adminNtfyPanel.serverLabel': 'Ntfy Server URL',
|
||||||
|
'admin.notifications.adminNtfyPanel.serverPlaceholder': 'https://ntfy.sh',
|
||||||
|
'admin.notifications.adminNtfyPanel.topicLabel': 'Admin Topic',
|
||||||
|
'admin.notifications.adminNtfyPanel.topicPlaceholder': 'trek-admin-alerts',
|
||||||
|
'admin.notifications.adminNtfyPanel.tokenLabel': 'Access Token (optional)',
|
||||||
|
'admin.notifications.adminNtfyPanel.saved': 'Admin ntfy settings saved',
|
||||||
|
'admin.notifications.adminNtfyPanel.test': 'Send test ntfy',
|
||||||
|
'admin.notifications.adminNtfyPanel.testSuccess': 'Test ntfy sent successfully',
|
||||||
|
'admin.notifications.adminNtfyPanel.testFailed': 'Test ntfy failed',
|
||||||
|
'admin.notifications.adminNtfyPanel.alwaysOnHint': 'Admin ntfy always fires when a topic is configured',
|
||||||
'admin.notifications.adminNotificationsHint': '配置哪些渠道傳遞僅管理員通知(例如版本提醒)。',
|
'admin.notifications.adminNotificationsHint': '配置哪些渠道傳遞僅管理員通知(例如版本提醒)。',
|
||||||
'admin.smtp.title': '郵件與通知',
|
'admin.smtp.title': '郵件與通知',
|
||||||
'admin.smtp.hint': '用於傳送電子郵件通知的 SMTP 配置。',
|
'admin.smtp.hint': '用於傳送電子郵件通知的 SMTP 配置。',
|
||||||
|
|||||||
@@ -66,6 +66,7 @@ const ADMIN_CHANNEL_LABEL_KEYS: Record<string, string> = {
|
|||||||
inapp: 'settings.notificationPreferences.inapp',
|
inapp: 'settings.notificationPreferences.inapp',
|
||||||
email: 'settings.notificationPreferences.email',
|
email: 'settings.notificationPreferences.email',
|
||||||
webhook: 'settings.notificationPreferences.webhook',
|
webhook: 'settings.notificationPreferences.webhook',
|
||||||
|
ntfy: 'settings.notificationPreferences.ntfy',
|
||||||
}
|
}
|
||||||
|
|
||||||
function AdminNotificationsPanel({ t, toast }: { t: (k: string) => string; toast: ReturnType<typeof useToast> }) {
|
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>
|
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
|
if (!matrix.available_channels[ch]) return false
|
||||||
return matrix.event_types.some((evt: string) => matrix.implemented_combos[evt]?.includes(ch))
|
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 activeChans = rawChannels === 'none' ? [] : rawChannels.split(',').map((c: string) => c.trim())
|
||||||
const emailActive = activeChans.includes('email')
|
const emailActive = activeChans.includes('email')
|
||||||
const webhookActive = activeChans.includes('webhook')
|
const webhookActive = activeChans.includes('webhook')
|
||||||
|
const ntfyActive = activeChans.includes('ntfy')
|
||||||
|
|
||||||
const setChannels = async (email: boolean, webhook: boolean) => {
|
const setChannels = async (email: boolean, webhook: boolean, ntfy: boolean) => {
|
||||||
const chans = [email && 'email', webhook && 'webhook'].filter(Boolean).join(',') || 'none'
|
const chans = [email && 'email', webhook && 'webhook', ntfy && 'ntfy'].filter(Boolean).join(',') || 'none'
|
||||||
setSmtpValues(prev => ({ ...prev, notification_channels: chans }))
|
setSmtpValues(prev => ({ ...prev, notification_channels: chans }))
|
||||||
try {
|
try {
|
||||||
await authApi.updateAppSettings({ notification_channels: chans })
|
await authApi.updateAppSettings({ notification_channels: chans })
|
||||||
} catch {
|
} catch {
|
||||||
// Revert state on failure
|
// 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 }))
|
setSmtpValues(prev => ({ ...prev, notification_channels: reverted }))
|
||||||
toast.error(t('common.error'))
|
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>
|
<p className="text-xs text-slate-400 mt-1">{t('admin.smtp.hint')}</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<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"
|
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)' }}
|
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>
|
<p className="text-xs text-slate-400 mt-1">{t('admin.webhook.hint')}</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<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"
|
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)' }}
|
style={{ background: webhookActive ? 'var(--text-primary)' : 'var(--border-primary)' }}
|
||||||
>
|
>
|
||||||
@@ -1293,6 +1295,24 @@ export default function AdminPage(): React.ReactElement {
|
|||||||
</div>
|
</div>
|
||||||
</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 */}
|
{/* In-App Panel */}
|
||||||
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden">
|
<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">
|
<div className="px-6 py-4 border-b border-slate-100 flex items-center justify-between">
|
||||||
@@ -1358,6 +1378,89 @@ export default function AdminPage(): React.ReactElement {
|
|||||||
</div>
|
</div>
|
||||||
</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"
|
||||||
|
/>
|
||||||
|
</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>
|
||||||
|
<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="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>
|
||||||
|
<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>
|
||||||
<div className="mt-6">
|
<div className="mt-6">
|
||||||
<AdminNotificationsPanel t={t} toast={toast} />
|
<AdminNotificationsPanel t={t} toast={toast} />
|
||||||
|
|||||||
@@ -61,6 +61,10 @@ export const notificationHandlers = [
|
|||||||
return HttpResponse.json({ success: true });
|
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 }) => {
|
http.post('/api/notifications/in-app/:id/respond', async ({ request, params }) => {
|
||||||
const body = await request.json() as { response: string };
|
const body = await request.json() as { response: string };
|
||||||
return HttpResponse.json({
|
return HttpResponse.json({
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import express, { Request, Response } from 'express';
|
import express, { Request, Response } from 'express';
|
||||||
import { authenticate } from '../middleware/auth';
|
import { authenticate } from '../middleware/auth';
|
||||||
import { AuthRequest } from '../types';
|
import { AuthRequest } from '../types';
|
||||||
import { testSmtp, testWebhook, getAdminWebhookUrl, getUserWebhookUrl } from '../services/notifications';
|
import { testSmtp, testWebhook, testNtfy, getAdminWebhookUrl, getUserWebhookUrl, getUserNtfyConfig, getAdminNtfyConfig } from '../services/notifications';
|
||||||
import {
|
import {
|
||||||
getNotifications,
|
getNotifications,
|
||||||
getUnreadCount,
|
getUnreadCount,
|
||||||
@@ -47,6 +47,26 @@ router.post('/test-webhook', authenticate, async (req: Request, res: Response) =
|
|||||||
res.json(await testWebhook(url));
|
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 ──────────────────────────────────────────────────────
|
// ── In-app notifications ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
// GET /in-app — list notifications (paginated)
|
// GET /in-app — list notifications (paginated)
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ const MFA_BACKUP_CODE_COUNT = 10;
|
|||||||
const ADMIN_SETTINGS_KEYS = [
|
const ADMIN_SETTINGS_KEYS = [
|
||||||
'allow_registration', 'allowed_file_types', 'require_mfa',
|
'allow_registration', 'allowed_file_types', 'require_mfa',
|
||||||
'smtp_host', 'smtp_port', 'smtp_user', 'smtp_pass', 'smtp_from', 'smtp_skip_tls_verify',
|
'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',
|
'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> = {};
|
const result: Record<string, string> = {};
|
||||||
for (const key of ADMIN_SETTINGS_KEYS) {
|
for (const key of ADMIN_SETTINGS_KEYS) {
|
||||||
const row = db.prepare("SELECT value FROM app_settings WHERE key = ?").get(key) as { value: string } | undefined;
|
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 };
|
return { data: result };
|
||||||
}
|
}
|
||||||
@@ -768,6 +768,8 @@ export function updateAppSettings(
|
|||||||
if (key === 'smtp_pass') val = encrypt_api_key(val);
|
if (key === 'smtp_pass') val = encrypt_api_key(val);
|
||||||
if (key === 'admin_webhook_url' && val === '••••••••') continue;
|
if (key === 'admin_webhook_url' && val === '••••••••') continue;
|
||||||
if (key === 'admin_webhook_url' && val) val = maybe_encrypt_api_key(val) ?? val;
|
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);
|
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_'));
|
const smtpChanged = changedKeys.some(k => k.startsWith('smtp_'));
|
||||||
if (changedKeys.includes('notification_channels')) summary.notification_channels = body.notification_channels;
|
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.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 (smtpChanged) summary.smtp_settings_updated = true;
|
||||||
if (changedKeys.includes('allow_registration')) summary.allow_registration = body.allow_registration;
|
if (changedKeys.includes('allow_registration')) summary.allow_registration = body.allow_registration;
|
||||||
if (changedKeys.includes('allowed_file_types')) summary.allowed_file_types_updated = true;
|
if (changedKeys.includes('allowed_file_types')) summary.allowed_file_types_updated = true;
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { decrypt_api_key } from './apiKeyCrypto';
|
|||||||
|
|
||||||
// ── Types ──────────────────────────────────────────────────────────────────
|
// ── Types ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export type NotifChannel = 'email' | 'webhook' | 'inapp';
|
export type NotifChannel = 'email' | 'webhook' | 'inapp' | 'ntfy';
|
||||||
|
|
||||||
export type NotifEventType =
|
export type NotifEventType =
|
||||||
| 'trip_invite'
|
| 'trip_invite'
|
||||||
@@ -20,19 +20,20 @@ export interface AvailableChannels {
|
|||||||
email: boolean;
|
email: boolean;
|
||||||
webhook: boolean;
|
webhook: boolean;
|
||||||
inapp: boolean;
|
inapp: boolean;
|
||||||
|
ntfy: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Which channels are implemented for each event type.
|
// Which channels are implemented for each event type.
|
||||||
// Only implemented combos show toggles in the user preferences UI.
|
// Only implemented combos show toggles in the user preferences UI.
|
||||||
const IMPLEMENTED_COMBOS: Record<NotifEventType, NotifChannel[]> = {
|
const IMPLEMENTED_COMBOS: Record<NotifEventType, NotifChannel[]> = {
|
||||||
trip_invite: ['inapp', 'email', 'webhook'],
|
trip_invite: ['inapp', 'email', 'webhook', 'ntfy'],
|
||||||
booking_change: ['inapp', 'email', 'webhook'],
|
booking_change: ['inapp', 'email', 'webhook', 'ntfy'],
|
||||||
trip_reminder: ['inapp', 'email', 'webhook'],
|
trip_reminder: ['inapp', 'email', 'webhook', 'ntfy'],
|
||||||
vacay_invite: ['inapp', 'email', 'webhook'],
|
vacay_invite: ['inapp', 'email', 'webhook', 'ntfy'],
|
||||||
photos_shared: ['inapp', 'email', 'webhook'],
|
photos_shared: ['inapp', 'email', 'webhook', 'ntfy'],
|
||||||
collab_message: ['inapp', 'email', 'webhook'],
|
collab_message: ['inapp', 'email', 'webhook', 'ntfy'],
|
||||||
packing_tagged: ['inapp', 'email', 'webhook'],
|
packing_tagged: ['inapp', 'email', 'webhook', 'ntfy'],
|
||||||
version_available: ['inapp', 'email', 'webhook'],
|
version_available: ['inapp', 'email', 'webhook', 'ntfy'],
|
||||||
synology_session_cleared: ['inapp'],
|
synology_session_cleared: ['inapp'],
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -55,7 +56,7 @@ function getAppSetting(key: string): string | null {
|
|||||||
export function getActiveChannels(): NotifChannel[] {
|
export function getActiveChannels(): NotifChannel[] {
|
||||||
const raw = getAppSetting('notification_channels') || getAppSetting('notification_channel') || 'none';
|
const raw = getAppSetting('notification_channels') || getAppSetting('notification_channel') || 'none';
|
||||||
if (raw === 'none') return [];
|
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 {
|
export function getAvailableChannels(): AvailableChannels {
|
||||||
const hasSmtp = !!(process.env.SMTP_HOST || getAppSetting('smtp_host'));
|
const hasSmtp = !!(process.env.SMTP_HOST || getAppSetting('smtp_host'));
|
||||||
const hasWebhook = getActiveChannels().includes('webhook');
|
const activeChannels = getActiveChannels();
|
||||||
return { email: hasSmtp, webhook: hasWebhook, inapp: true };
|
return { email: hasSmtp, webhook: activeChannels.includes('webhook'), ntfy: activeChannels.includes('ntfy'), inapp: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Per-user preference checks ─────────────────────────────────────────────
|
// ── Per-user preference checks ─────────────────────────────────────────────
|
||||||
@@ -115,8 +116,8 @@ export function getPreferencesMatrix(userId: number, userRole: string, scope: 'u
|
|||||||
const channels = IMPLEMENTED_COMBOS[eventType];
|
const channels = IMPLEMENTED_COMBOS[eventType];
|
||||||
preferences[eventType] = {};
|
preferences[eventType] = {};
|
||||||
for (const channel of channels) {
|
for (const channel of channels) {
|
||||||
// Admin-scoped events use global settings for email/webhook
|
// Admin-scoped events use global settings for email/webhook/ntfy
|
||||||
if (scope === 'admin' && ADMIN_SCOPED_EVENTS.has(eventType) && (channel === 'email' || channel === 'webhook')) {
|
if (scope === 'admin' && ADMIN_SCOPED_EVENTS.has(eventType) && (channel === 'email' || channel === 'webhook' || channel === 'ntfy')) {
|
||||||
preferences[eventType]![channel] = getAdminGlobalPref(eventType, channel);
|
preferences[eventType]![channel] = getAdminGlobalPref(eventType, channel);
|
||||||
} else {
|
} else {
|
||||||
preferences[eventType]![channel] = stored[eventType]?.[channel] ?? true;
|
preferences[eventType]![channel] = stored[eventType]?.[channel] ?? true;
|
||||||
@@ -134,12 +135,14 @@ export function getPreferencesMatrix(userId: number, userRole: string, scope: 'u
|
|||||||
if (scope === 'admin') {
|
if (scope === 'admin') {
|
||||||
const hasSmtp = !!(process.env.SMTP_HOST || getAppSetting('smtp_host'));
|
const hasSmtp = !!(process.env.SMTP_HOST || getAppSetting('smtp_host'));
|
||||||
const hasAdminWebhook = !!(getAppSetting('admin_webhook_url'));
|
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 {
|
} else {
|
||||||
const activeChannels = getActiveChannels();
|
const activeChannels = getActiveChannels();
|
||||||
available_channels = {
|
available_channels = {
|
||||||
email: activeChannels.includes('email'),
|
email: activeChannels.includes('email'),
|
||||||
webhook: activeChannels.includes('webhook'),
|
webhook: activeChannels.includes('webhook'),
|
||||||
|
ntfy: activeChannels.includes('ntfy'),
|
||||||
inapp: true,
|
inapp: true,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -154,19 +157,19 @@ export function getPreferencesMatrix(userId: number, userRole: string, scope: 'u
|
|||||||
|
|
||||||
// ── Admin global preferences (stored in app_settings) ─────────────────────
|
// ── 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.
|
* Returns the global admin preference for an event+channel.
|
||||||
* Stored in app_settings as `admin_notif_pref_{event}_{channel}`.
|
* Stored in app_settings as `admin_notif_pref_{event}_{channel}`.
|
||||||
* Defaults to true (enabled) when no row exists.
|
* 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}`);
|
const val = getAppSetting(`admin_notif_pref_${event}_${channel}`);
|
||||||
return val !== '0';
|
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(
|
db.prepare('INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)').run(
|
||||||
`admin_notif_pref_${event}_${channel}`,
|
`admin_notif_pref_${event}_${channel}`,
|
||||||
enabled ? '1' : '0'
|
enabled ? '1' : '0'
|
||||||
@@ -250,7 +253,7 @@ export function setAdminPreferences(
|
|||||||
for (const [eventType, channels] of Object.entries(globalPrefs)) {
|
for (const [eventType, channels] of Object.entries(globalPrefs)) {
|
||||||
if (!channels) continue;
|
if (!channels) continue;
|
||||||
for (const [channel, enabled] of Object.entries(channels)) {
|
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,
|
isSmtpConfigured,
|
||||||
ADMIN_SCOPED_EVENTS,
|
ADMIN_SCOPED_EVENTS,
|
||||||
type NotifEventType,
|
type NotifEventType,
|
||||||
|
type NotifChannel,
|
||||||
} from './notificationPreferencesService';
|
} from './notificationPreferencesService';
|
||||||
import {
|
import {
|
||||||
getEventText,
|
getEventText,
|
||||||
sendEmail,
|
sendEmail,
|
||||||
sendWebhook,
|
sendWebhook,
|
||||||
|
sendNtfy,
|
||||||
getUserEmail,
|
getUserEmail,
|
||||||
getUserLanguage,
|
getUserLanguage,
|
||||||
getUserWebhookUrl,
|
getUserWebhookUrl,
|
||||||
getAdminWebhookUrl,
|
getAdminWebhookUrl,
|
||||||
|
getUserNtfyConfig,
|
||||||
|
getAdminNtfyConfig,
|
||||||
|
resolveNtfyUrl,
|
||||||
getAppUrl,
|
getAppUrl,
|
||||||
} from './notifications';
|
} from './notifications';
|
||||||
import {
|
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);
|
const results = await Promise.allSettled(promises);
|
||||||
for (const result of results) {
|
for (const result of results) {
|
||||||
if (result.status === 'rejected') {
|
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 { db } from '../db/database';
|
||||||
import { maybe_encrypt_api_key } from './apiKeyCrypto';
|
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> {
|
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 }[];
|
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.status).toBe(200);
|
||||||
expect(res.body).toHaveProperty('success');
|
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);
|
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 { user } = createAdmin(testDb);
|
||||||
const { implemented_combos } = getPreferencesMatrix(user.id, 'admin', 'admin');
|
const { implemented_combos } = getPreferencesMatrix(user.id, 'admin', 'admin');
|
||||||
expect(implemented_combos['version_available']).toEqual(['inapp', 'email', 'webhook']);
|
expect(implemented_combos['version_available']).toEqual(['inapp', 'email', 'webhook', 'ntfy']);
|
||||||
// All events now support all three channels
|
// All events now support all four channels
|
||||||
expect(implemented_combos['trip_invite']).toContain('inapp');
|
expect(implemented_combos['trip_invite']).toContain('inapp');
|
||||||
expect(implemented_combos['trip_invite']).toContain('email');
|
expect(implemented_combos['trip_invite']).toContain('email');
|
||||||
expect(implemented_combos['trip_invite']).toContain('webhook');
|
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);
|
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(() => ({})),
|
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 { checkSsrf } from '../../../src/utils/ssrfGuard';
|
||||||
import { logError } from '../../../src/services/auditLog';
|
import { logError } from '../../../src/services/auditLog';
|
||||||
|
|
||||||
@@ -319,3 +319,140 @@ describe('sendWebhook SSRF protection (SEC-017)', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
afterAll(() => vi.unstubAllGlobals());
|
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