feat(notifications): admin defaults for users, and channels blocked for everyone

Admin > Notifications gets a Defaults for users matrix: each event and channel starts On, Off (a user may still turn it on) or Blocked (off for everyone). A user's own choice is kept against the default, and a value equal to it is stored as no row, so a later change of the default reaches everyone who never chose. Blocked cells show as locked in the user settings on desktop and phone, and the server ignores any attempt to turn one on. (#1536)
This commit is contained in:
Maurice
2026-10-01 12:28:36 +02:00
parent 7acf7f5655
commit f4626c9f3e
70 changed files with 680 additions and 57 deletions
+3 -1
View File
@@ -2,7 +2,7 @@ import { isEffectivelyOffline } from '../sync/networkMode'
import axios, { AxiosInstance } from 'axios'
import type { z } from 'zod'
import type { Day, Place, Trip } from '../types'
import type { MapsNearbyRequest, JourneyReorderEntryPhotosRequest, TransitProvider, GoogleQuotaUpdateRequest } from '@trek/shared'
import type { MapsNearbyRequest, JourneyReorderEntryPhotosRequest, TransitProvider, GoogleQuotaUpdateRequest, NotificationDefaultsUpdateRequest } from '@trek/shared'
import { randomId } from '../utils/randomId'
import { postProviderPhotosInBatches } from './providerPhotoBatches'
import {
@@ -732,6 +732,8 @@ export const adminApi = {
apiClient.post('/admin/dev/test-notification', data).then(r => r.data),
getNotificationPreferences: () => apiClient.get('/admin/notification-preferences').then(r => r.data),
updateNotificationPreferences: (prefs: Record<string, Record<string, boolean>>) => apiClient.put('/admin/notification-preferences', prefs).then(r => r.data),
getNotificationDefaults: () => apiClient.get('/admin/notification-preferences/defaults').then(r => r.data),
updateNotificationDefaults: (defaults: NotificationDefaultsUpdateRequest['defaults']) => apiClient.put('/admin/notification-preferences/defaults', { defaults } satisfies NotificationDefaultsUpdateRequest).then(r => r.data),
getDefaultUserSettings: () => apiClient.get('/admin/default-user-settings').then(r => r.data),
updateDefaultUserSettings: (settings: Record<string, unknown>) => apiClient.put('/admin/default-user-settings', settings).then(r => r.data),
getStorage: (): Promise<StorageAdminState> => apiClient.get('/admin/storage').then(r => r.data),
@@ -2,7 +2,7 @@ import userEvent from '@testing-library/user-event';
import { http, HttpResponse } from 'msw';
import { buildUser } from '../../../tests/helpers/factories';
import { server } from '../../../tests/helpers/msw/server';
import { render, screen, waitFor } from '../../../tests/helpers/render';
import { render, screen, waitFor, fireEvent } from '../../../tests/helpers/render';
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
import { useAuthStore } from '../../store/authStore';
import { ToastContainer } from '../shared/Toast';
@@ -1229,3 +1229,23 @@ describe('NotificationsTab: Web Push', () => {
expect(screen.queryByText('Push')).not.toBeInTheDocument();
});
});
describe('NotificationsTab — cells the admin blocked (#1536)', () => {
it('FE-COMP-NOTIFICATIONS-LOCK-001: shows a lock instead of a switch, and sends nothing for it', async () => {
let puts = 0
server.use(
http.get('/api/notifications/preferences', () => HttpResponse.json({
...minimalMatrix,
preferences: { trip_invite: { inapp: true, email: false } },
locked: { trip_invite: ['email'] },
})),
http.put('/api/notifications/preferences', () => { puts += 1; return HttpResponse.json({ success: true }) }),
)
render(<NotificationsTab />)
const lock = await screen.findByRole('img', { name: 'Turned off for everyone by the admin' })
fireEvent.click(lock)
// The in-app switch for the same event is still there and still a switch.
expect(screen.getAllByRole('button', { pressed: true })).toHaveLength(1)
expect(puts).toBe(0)
})
})
@@ -8,6 +8,8 @@ import { useToast } from '../shared/Toast'
import ToggleSwitch from './ToggleSwitch'
import Section from './Section'
import WebPushCard from './WebPushCard'
import { EVENT_LABEL_KEYS, channelLabel, isLockedCell } from './notificationLabels'
import { Tooltip } from '../shared/Tooltip'
interface ChannelDescriptor {
id: string
@@ -26,30 +28,11 @@ interface PreferencesMatrix {
channels: ChannelDescriptor[]
event_types: string[]
implemented_combos: Record<string, string[]>
/** Cells the admin switched off for everyone (#1536). */
locked?: Record<string, string[]>
defaults?: { ntfyServer: string | null }
}
/** Plugin channels have no i18n — the server sends their display name outright. */
function channelLabel(ch: ChannelDescriptor, t: (k: string) => string): string {
if (ch.labelKey) return t(ch.labelKey) || ch.id
return ch.label || ch.id
}
const EVENT_LABEL_KEYS: Record<string, string> = {
trip_invite: 'settings.notifyTripInvite',
booking_change: 'settings.notifyBookingChange',
trip_reminder: 'settings.notifyTripReminder',
todo_due: 'settings.notifyTodoDue',
vacay_invite: 'settings.notifyVacayInvite',
vacay_share: 'settings.notifyVacayShare',
collection_invite: 'settings.notifyCollectionInvite',
synology_session_cleared: 'settings.notifySynologySessionCleared',
plugin_notification: 'settings.notifyPluginNotification',
photos_shared: 'settings.notifyPhotosShared',
collab_message: 'settings.notifyCollabMessage',
packing_tagged: 'settings.notifyPackingTagged',
version_available: 'settings.notifyVersionAvailable',
}
export default function NotificationsTab(): React.ReactElement {
const { t } = useTranslation()
@@ -119,7 +102,7 @@ export default function NotificationsTab(): React.ReactElement {
}
const toggle = async (eventType: string, channel: string) => {
if (!matrix) return
if (!matrix || isLockedCell(matrix.locked, eventType, channel)) return
const current = matrix.preferences[eventType]?.[channel] ?? true
setMatrix(m => m ? {
...m,
@@ -383,6 +366,20 @@ export default function NotificationsTab(): React.ReactElement {
return <span key={ch.id} style={{ textAlign: 'center', color: 'var(--text-faint)', fontSize: 'calc(14px * var(--fs-scale-body, 1))' }}>—</span>
}
const isOn = matrix.preferences[eventType]?.[ch.id] ?? true
// Switched off for everyone by the admin (#1536): the same footprint as
// the switch, so the grid keeps its columns, and a lock that says why.
if (isLockedCell(matrix.locked, eventType, ch.id)) {
return (
<div key={ch.id} style={{ display: 'flex', justifyContent: 'center' }}>
<Tooltip label={t('settings.notificationPreferences.lockedByAdmin')}>
<span role="img" aria-label={t('settings.notificationPreferences.lockedByAdmin')} data-locked
className="inline-flex h-6 w-11 items-center justify-center rounded-full bg-surface-tertiary text-content-faint">
<Lock size={12} />
</span>
</Tooltip>
</div>
)
}
return (
<div key={ch.id} style={{ display: 'flex', justifyContent: 'center' }}>
<ToggleSwitch on={isOn} onToggle={() => toggle(eventType, ch.id)} />
@@ -0,0 +1,42 @@
/**
* Labels for the notification matrix, shared by the desktop and phone settings
* and the admin's defaults for users (#1536), so the three can never name an
* event differently.
*/
export interface NotificationChannelDescriptor {
id: string
source?: 'builtin' | 'plugin'
labelKey?: string
label?: string
settingsPath?: string
active: boolean
configured: boolean
}
export const EVENT_LABEL_KEYS: Record<string, string> = {
trip_invite: 'settings.notifyTripInvite',
booking_change: 'settings.notifyBookingChange',
trip_reminder: 'settings.notifyTripReminder',
todo_due: 'settings.notifyTodoDue',
vacay_invite: 'settings.notifyVacayInvite',
vacay_share: 'settings.notifyVacayShare',
collection_invite: 'settings.notifyCollectionInvite',
synology_session_cleared: 'settings.notifySynologySessionCleared',
plugin_notification: 'settings.notifyPluginNotification',
photos_shared: 'settings.notifyPhotosShared',
collab_message: 'settings.notifyCollabMessage',
packing_tagged: 'settings.notifyPackingTagged',
version_available: 'settings.notifyVersionAvailable',
}
/** Plugin channels have no i18n; the server sends their display name outright. */
export function channelLabel(ch: { id: string; labelKey?: string; label?: string }, t: (k: string) => string): string {
if (ch.labelKey) return t(ch.labelKey) || ch.id
return ch.label || ch.id
}
/** True when the admin switched this cell off for everyone (#1536). */
export function isLockedCell(locked: Record<string, string[]> | undefined, eventType: string, channel: string): boolean {
return !!locked?.[eventType]?.includes(channel)
}
@@ -1,6 +1,6 @@
import { useEffect, useState } from 'react'
import { useNavigate } from 'react-router'
import { Bell, Link2, Send } from 'lucide-react'
import { Bell, Link2, Lock, Send } from 'lucide-react'
import { WEB_PUSH_CHANNEL_ID } from '@trek/shared'
import { useTranslation } from '../../../i18n'
import { notificationsApi, settingsApi } from '../../../api/client'
@@ -8,6 +8,7 @@ import { useToast } from '../../../components/shared/Toast'
import { MSetCard, MSetEyebrow, MSetInput, MSetButton, MSetHint } from './MSettingsUi'
import MChip from '../../components/MChip'
import MWebPushCard from './MWebPushCard'
import { EVENT_LABEL_KEYS, channelLabel, isLockedCell } from '../../../components/Settings/notificationLabels'
interface ChannelDescriptor {
id: string
@@ -26,29 +27,11 @@ interface PreferencesMatrix {
channels: ChannelDescriptor[]
event_types: string[]
implemented_combos: Record<string, string[]>
/** Cells the admin switched off for everyone (#1536). */
locked?: Record<string, string[]>
defaults?: { ntfyServer: string | null }
}
function channelLabel(ch: ChannelDescriptor, t: (k: string) => string): string {
if (ch.labelKey) return t(ch.labelKey) || ch.id
return ch.label || ch.id
}
const EVENT_LABEL_KEYS: Record<string, string> = {
trip_invite: 'settings.notifyTripInvite',
booking_change: 'settings.notifyBookingChange',
trip_reminder: 'settings.notifyTripReminder',
todo_due: 'settings.notifyTodoDue',
vacay_invite: 'settings.notifyVacayInvite',
vacay_share: 'settings.notifyVacayShare',
collection_invite: 'settings.notifyCollectionInvite',
synology_session_cleared: 'settings.notifySynologySessionCleared',
plugin_notification: 'settings.notifyPluginNotification',
photos_shared: 'settings.notifyPhotosShared',
collab_message: 'settings.notifyCollabMessage',
packing_tagged: 'settings.notifyPackingTagged',
version_available: 'settings.notifyVersionAvailable',
}
const MASKED = '••••••••'
@@ -121,7 +104,7 @@ export default function MSettingsNotifications() {
}
const toggle = async (eventType: string, channel: string) => {
if (!matrix) return
if (!matrix || isLockedCell(matrix.locked, eventType, channel)) return
const current = matrix.preferences[eventType]?.[channel] ?? true
const updated = {
...matrix.preferences,
@@ -324,6 +307,17 @@ export default function MSettingsNotifications() {
<div className="flex flex-wrap gap-[6px]">
{relevantChannels.map((ch) => {
const isOn = matrix.preferences[eventType]?.[ch.id] ?? true
// Switched off for everyone by the admin (#1536): shown, but locked.
if (isLockedCell(matrix.locked, eventType, ch.id)) {
return (
<span key={ch.id} aria-disabled="true" data-locked
aria-label={`${channelLabel(ch, t)}: ${t('settings.notificationPreferences.lockedByAdmin')}`}
className="inline-flex flex-none items-center gap-[5px] rounded-full border border-dashed border-[color:var(--m-rowbr)] px-3 py-[7px] text-[0.75rem] font-semibold text-m-faint">
<Lock size={11} strokeWidth={2.4} aria-hidden="true" />
{channelLabel(ch, t)}
</span>
)
}
return (
<MChip key={ch.id} active={isOn} onClick={() => toggle(eventType, ch.id)}>
{channelLabel(ch, t)}
@@ -0,0 +1,58 @@
// FE-ADMIN-NOTIFDEF-001 to FE-ADMIN-NOTIFDEF-003
import { describe, it, expect } from 'vitest'
import { http, HttpResponse } from 'msw'
import { render, screen, fireEvent, waitFor } from '../../../tests/helpers/render'
import { server } from '../../../tests/helpers/msw/server'
import AdminNotificationDefaultsPanel from './AdminNotificationDefaultsPanel'
const matrix = (defaults: Record<string, Record<string, string>>) => ({
defaults,
channels: [
{ id: 'inapp', labelKey: 'settings.notificationPreferences.inapp', active: true, configured: true },
{ id: 'email', labelKey: 'settings.notificationPreferences.email', active: true, configured: true },
],
event_types: ['trip_invite', 'booking_change'],
implemented_combos: { trip_invite: ['inapp', 'email'], booking_change: ['inapp'] },
})
describe('AdminNotificationDefaultsPanel (#1536)', () => {
it('FE-ADMIN-NOTIFDEF-001: draws each cell in its state, and a dash where the channel does not apply', async () => {
server.use(http.get('/api/admin/notification-preferences/defaults', () => HttpResponse.json(matrix({
trip_invite: { inapp: 'on', email: 'blocked' }, booking_change: { inapp: 'off' },
}))))
render(<AdminNotificationDefaultsPanel />)
expect(await screen.findByText('Defaults for users')).toBeInTheDocument()
const states = Array.from(document.querySelectorAll('button[data-state]')).map(b => b.getAttribute('data-state'))
expect(states).toEqual(['on', 'blocked', 'off'])
expect(screen.getByText('—')).toBeInTheDocument()
})
it('FE-ADMIN-NOTIFDEF-002: a click moves the cell on to the next state and saves just that cell', async () => {
const sent: unknown[] = []
server.use(
http.get('/api/admin/notification-preferences/defaults', () => HttpResponse.json(matrix({ trip_invite: { inapp: 'on', email: 'on' }, booking_change: { inapp: 'on' } }))),
http.put('/api/admin/notification-preferences/defaults', async ({ request }) => {
const body = await request.json() as { defaults: Record<string, Record<string, string>> }
sent.push(body)
return HttpResponse.json(matrix({ trip_invite: { inapp: 'on', email: 'off' }, booking_change: { inapp: 'on' } }))
}),
)
render(<AdminNotificationDefaultsPanel />)
await screen.findByText('Defaults for users')
const emailCell = document.querySelectorAll('button[data-state]')[1] as HTMLElement
fireEvent.click(emailCell)
await waitFor(() => expect(sent).toEqual([{ defaults: { trip_invite: { email: 'off' } } }]))
await waitFor(() => expect(document.querySelectorAll('button[data-state]')[1].getAttribute('data-state')).toBe('off'))
})
it('FE-ADMIN-NOTIFDEF-003: a refused save puts the cell back', async () => {
server.use(
http.get('/api/admin/notification-preferences/defaults', () => HttpResponse.json(matrix({ trip_invite: { inapp: 'on', email: 'off' }, booking_change: { inapp: 'on' } }))),
http.put('/api/admin/notification-preferences/defaults', () => HttpResponse.json({ error: 'nope' }, { status: 500 })),
)
render(<AdminNotificationDefaultsPanel />)
await screen.findByText('Defaults for users')
fireEvent.click(document.querySelectorAll('button[data-state]')[1] as HTMLElement)
await waitFor(() => expect(document.querySelectorAll('button[data-state]')[1].getAttribute('data-state')).toBe('off'))
})
})
@@ -0,0 +1,91 @@
import { Check, Lock, Minus } from 'lucide-react'
import type { NotificationDefault } from '@trek/shared'
import { useTranslation } from '../../i18n'
import { Tooltip } from '../../components/shared/Tooltip'
import { EVENT_LABEL_KEYS, channelLabel } from '../../components/Settings/notificationLabels'
import { useNotificationDefaults } from './useNotificationDefaults'
const NEXT: Record<NotificationDefault, NotificationDefault> = { on: 'off', off: 'blocked', blocked: 'on' }
const ICON = { on: Check, off: Minus, blocked: Lock } as const
const TONE: Record<NotificationDefault, string> = {
on: 'bg-accent text-accent-text border-transparent',
off: 'bg-surface-card text-content-muted border-edge',
blocked: 'bg-danger-soft text-danger border-transparent',
}
const LABEL_KEYS: Record<NotificationDefault, string> = {
on: 'admin.notificationDefaults.on',
off: 'admin.notificationDefaults.off',
blocked: 'admin.notificationDefaults.blocked',
}
/**
* What every user's notification cells start as (#1536). One cell per event and
* channel, cycling On → Off → Blocked: Off is a default each user may still turn
* on, Blocked is off for everyone and shows as locked in their settings.
*/
export default function AdminNotificationDefaultsPanel() {
const { t } = useTranslation()
const { matrix, saving, cycle } = useNotificationDefaults()
if (!matrix) return null
const channels = matrix.channels.filter(ch => matrix.event_types.some(evt => matrix.implemented_combos[evt]?.includes(ch.id)))
const columns = `minmax(0, 1fr) ${channels.map(() => '76px').join(' ')}`
return (
<section className="rounded-xl border border-edge bg-surface-card" aria-labelledby="notif-defaults-title">
<div className="flex flex-wrap items-start justify-between gap-3 border-b border-edge-faint px-6 py-4">
<div className="min-w-0 flex-1 basis-72">
<h2 id="notif-defaults-title" className="font-semibold text-content">{t('admin.notificationDefaults.title')}</h2>
<p className="mt-1 text-xs text-content-faint">{t('admin.notificationDefaults.hint')}</p>
</div>
{/* The legend is the key to the cells below, so it reads in the same three chips. */}
<div className="flex flex-wrap items-center gap-1.5" aria-hidden="true">
{(['on', 'off', 'blocked'] as const).map(state => {
const Icon = ICON[state]
return (
<span key={state} className={`inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-xs font-medium ${TONE[state]}`}>
<Icon size={11} strokeWidth={2.6} />
{t(LABEL_KEYS[state])}
</span>
)
})}
</div>
</div>
<div className="overflow-x-auto px-6 pb-5 pt-3">
<div className="min-w-max">
<div className="grid items-end gap-1 border-b border-edge pb-1.5" style={{ gridTemplateColumns: columns }}>
<span />
{channels.map(ch => (
<span key={ch.id} className="text-center text-caption font-semibold uppercase tracking-[0.04em] text-content-faint">{channelLabel(ch, t)}</span>
))}
</div>
{matrix.event_types.map(eventType => {
const implemented = matrix.implemented_combos[eventType] ?? []
return (
<div key={eventType} className="grid items-center gap-1 border-b border-edge-faint py-2 last:border-b-0" style={{ gridTemplateColumns: columns }}>
<span className="min-w-0 truncate text-body text-content">{t(EVENT_LABEL_KEYS[eventType]) || eventType}</span>
{channels.map(ch => {
if (!implemented.includes(ch.id)) return <span key={ch.id} className="text-center text-content-faint">—</span>
const state = matrix.defaults[eventType]?.[ch.id] ?? 'on'
const Icon = ICON[state]
const label = `${t(EVENT_LABEL_KEYS[eventType]) || eventType}, ${channelLabel(ch, t)}: ${t(LABEL_KEYS[state])}`
return (
<div key={ch.id} className="flex justify-center">
<Tooltip label={t('admin.notificationDefaults.cycle', { next: t(LABEL_KEYS[NEXT[state]]) })}>
<button type="button" aria-label={label} data-state={state} disabled={saving}
onClick={() => void cycle(eventType, ch.id, NEXT[state])}
className={`inline-flex h-7 w-12 items-center justify-center rounded-full border transition-colors disabled:opacity-60 ${TONE[state]}`}>
<Icon size={13} strokeWidth={2.6} />
</button>
</Tooltip>
</div>
)
})}
</div>
)
})}
</div>
</div>
</section>
)
}
@@ -4,6 +4,7 @@ import { Save } from 'lucide-react'
import type { TranslationFn } from '../../types'
import type { useAdmin } from './useAdmin'
import AdminNotificationsPanel from './AdminNotificationsPanel'
import AdminNotificationDefaultsPanel from './AdminNotificationDefaultsPanel'
import { SWITCH_ONLY_CHANNELS, useNotificationChannels } from '../../components/Admin/useNotificationChannels'
interface AdminNotificationsTabProps {
@@ -364,5 +365,11 @@ export default function AdminNotificationsTab({ admin, t }: AdminNotificationsTa
<AdminNotificationsPanel t={t} toast={toast} />
</div>
</div>
{/* What users' own notifications start as, across the full width: one row per
user event, one column per channel users can pick (#1536). */}
<div className="mt-6">
<AdminNotificationDefaultsPanel />
</div>
</>)
}
@@ -0,0 +1,54 @@
import { useEffect, useRef, useState } from 'react'
import type { NotificationDefault } from '@trek/shared'
import { adminApi } from '../../api/client'
import { useToast } from '../../components/shared/Toast'
import { useTranslation } from '../../i18n'
import type { NotificationChannelDescriptor } from '../../components/Settings/notificationLabels'
export interface NotificationDefaultsMatrix {
defaults: Record<string, Record<string, NotificationDefault>>
channels: NotificationChannelDescriptor[]
event_types: string[]
implemented_combos: Record<string, string[]>
}
/**
* The admin's defaults for user notifications (#1536). Each change saves at
* once and is rolled back with a toast when the server refuses it.
*/
export function useNotificationDefaults() {
const { t } = useTranslation()
const toast = useToast()
const [matrix, setMatrix] = useState<NotificationDefaultsMatrix | null>(null)
const [saving, setSaving] = useState(false)
const notify = useRef({ toast, t })
notify.current = { toast, t }
useEffect(() => {
let cancelled = false
adminApi.getNotificationDefaults()
.then((data: NotificationDefaultsMatrix) => { if (!cancelled) setMatrix(data) })
.catch(() => { if (!cancelled) notify.current.toast.error(notify.current.t('common.error')) })
return () => { cancelled = true }
}, [])
const cycle = async (eventType: string, channel: string, next: NotificationDefault) => {
if (!matrix) return
const before = matrix.defaults[eventType]?.[channel] ?? 'on'
const apply = (value: NotificationDefault) =>
setMatrix(m => m ? { ...m, defaults: { ...m.defaults, [eventType]: { ...m.defaults[eventType], [channel]: value } } } : m)
apply(next)
setSaving(true)
try {
const fresh: NotificationDefaultsMatrix = await adminApi.updateNotificationDefaults({ [eventType]: { [channel]: next } })
setMatrix(fresh)
} catch {
apply(before)
toast.error(t('common.error'))
} finally {
setSaving(false)
}
}
return { matrix, saving, cycle }
}
@@ -628,3 +628,13 @@ describe('MSettingsNotifications: Web Push', () => {
expect(screen.queryByText('Push notifications on this device')).not.toBeInTheDocument();
});
});
describe('MSettingsNotifications — cells the admin blocked (#1536)', () => {
it('FE-MOB-SETNOTIF-LOCK-001: a blocked channel shows as a locked chip, not a toggle', async () => {
usePrefs(matrix({ locked: { trip_invite: ['webhook'] } }));
render(<MSettingsNotifications />);
const chip = await screen.findByLabelText(/Turned off for everyone by the admin/);
expect(chip).toHaveAttribute('aria-disabled', 'true');
expect(chip.tagName).toBe('SPAN');
});
});
+2
View File
@@ -10,6 +10,7 @@ import {
adminAddonUpdateRequestSchema,
adminCollabFeaturesRequestSchema,
adminNotificationPreferencesRequestSchema,
notificationDefaultsUpdateRequestSchema,
adminDefaultUserSettingsRequestSchema,
adminTestNotificationRequestSchema,
adminTransitProviderRequestSchema,
@@ -35,6 +36,7 @@ export class AdminOidcUpdateDto extends createZodDto(adminOidcUpdateRequestSchem
export class AdminAddonUpdateDto extends createZodDto(adminAddonUpdateRequestSchema) {}
export class AdminCollabFeaturesDto extends createZodDto(adminCollabFeaturesRequestSchema) {}
export class AdminNotificationPreferencesDto extends createZodDto(adminNotificationPreferencesRequestSchema) {}
export class NotificationDefaultsUpdateDto extends createZodDto(notificationDefaultsUpdateRequestSchema) {}
export class AdminDefaultUserSettingsDto extends createZodDto(adminDefaultUserSettingsRequestSchema) {}
export class AdminTestNotificationDto extends createZodDto(adminTestNotificationRequestSchema) {}
export class AdminTransitProviderDto extends createZodDto(adminTransitProviderRequestSchema) {}
@@ -1,4 +1,5 @@
import { Injectable } from '@nestjs/common';
import { NOTIFICATION_DEFAULTS, type NotificationDefault } from '@trek/shared';
import { DatabaseService } from '../database/database.service';
import { MailerService } from './mailer/mailer.service';
import { listChannels } from './channel-registry';
@@ -21,8 +22,20 @@ export interface PreferencesMatrix {
event_types: NotifEventType[];
implemented_combos: Record<string, NotifChannel[]>;
defaults?: { ntfyServer: string | null };
/** Cells the admin switched off for everyone (#1536), event → channels. User scope only. */
locked?: Partial<Record<NotifEventType, NotifChannel[]>>;
}
/** The admin's per-cell defaults for user notifications, with the matrix to draw them in (#1536). */
export interface InstanceDefaultsMatrix {
defaults: Partial<Record<NotifEventType, Partial<Record<NotifChannel, NotificationDefault>>>>;
channels: ChannelDescriptor[];
event_types: NotifEventType[];
implemented_combos: Record<string, NotifChannel[]>;
}
const DEFAULT_KEY = (event: string, channel: string) => `notif_default_${event}_${channel}`;
/**
* Who gets which notification over which channel: the admin's channel switches,
* the per-user event opt-outs, and the matrix the settings UI renders.
@@ -97,11 +110,57 @@ export class NotificationPreferencesService {
* Default (no row) = enabled. Only returns false if there's an explicit disabled row.
*/
isEnabledForEvent(userId: number, eventType: NotifEventType, channel: NotifChannel): boolean {
const fallback = this.instanceDefault(eventType, channel);
if (fallback === 'blocked') return false;
const row = this.db.get<{ enabled: number }>(
'SELECT enabled FROM notification_channel_preferences WHERE user_id = ? AND event_type = ? AND channel = ?',
userId, eventType, channel,
);
return row === undefined || row.enabled === 1;
return row === undefined ? fallback === 'on' : row.enabled === 1;
}
// ── Instance defaults (#1536) ──────────────────────────────────────────────
/**
* What a cell is for a user who never touched it. Admin-scoped events keep
* their own global switches and are never affected; an unknown stored value
* means the old behaviour, on.
*/
instanceDefault(eventType: NotifEventType, channel: NotifChannel): NotificationDefault {
if (ADMIN_SCOPED_EVENTS.has(eventType)) return 'on';
const raw = this.getAppSetting(DEFAULT_KEY(eventType, channel));
return (NOTIFICATION_DEFAULTS as readonly string[]).includes(raw ?? '') ? (raw as NotificationDefault) : 'on';
}
private userEventTypes(): NotifEventType[] {
return ALL_EVENT_TYPES.filter(e => !ADMIN_SCOPED_EVENTS.has(e));
}
getInstanceDefaults(adminId: number): InstanceDefaultsMatrix {
const event_types = this.userEventTypes();
const implemented_combos = this.allCombos();
const defaults: InstanceDefaultsMatrix['defaults'] = {};
for (const event of event_types) {
defaults[event] = {};
for (const channel of implemented_combos[event]) defaults[event]![channel] = this.instanceDefault(event, channel);
}
return { defaults, channels: this.describeChannels(adminId, 'user'), event_types, implemented_combos };
}
/** Stores the cells given; `on` removes the row, so the table only holds what differs. Unknown cells are skipped. */
setInstanceDefaults(defaults: Partial<Record<string, Partial<Record<string, NotificationDefault>>>>): void {
const userEvents = new Set<string>(this.userEventTypes());
this.db.transaction(() => {
for (const [event, channels] of Object.entries(defaults)) {
if (!userEvents.has(event) || !channels) continue;
const allowed = new Set(this.combosFor(event as NotifEventType));
for (const [channel, value] of Object.entries(channels)) {
if (!allowed.has(channel) || !value) continue;
if (value === 'on') this.db.run('DELETE FROM app_settings WHERE key = ?', DEFAULT_KEY(event, channel));
else this.db.run('INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)', DEFAULT_KEY(event, channel), value);
}
}
});
}
// ── Preferences matrix ─────────────────────────────────────────────────────
@@ -185,8 +244,10 @@ export class NotificationPreferencesService {
const implemented_combos = this.allCombos();
// Build the full matrix with defaults (true when no row exists)
// Build the full matrix with defaults: the admin's instance default when no
// row exists (on unless set otherwise), and off for a cell the admin blocked.
const preferences: Partial<Record<NotifEventType, Partial<Record<NotifChannel, boolean>>>> = {};
const locked: Partial<Record<NotifEventType, NotifChannel[]>> = {};
for (const eventType of ALL_EVENT_TYPES) {
preferences[eventType] = {};
@@ -194,8 +255,14 @@ export class NotificationPreferencesService {
// Admin-scoped events use global settings for the built-in external channels
if (scope === 'admin' && ADMIN_SCOPED_EVENTS.has(eventType) && isAdminGlobalChannel(channel)) {
preferences[eventType]![channel] = this.getAdminGlobalPref(eventType, channel);
continue;
}
const fallback = this.instanceDefault(eventType, channel);
if (fallback === 'blocked') {
preferences[eventType]![channel] = false;
(locked[eventType] ??= []).push(channel);
} else {
preferences[eventType]![channel] = stored[eventType]?.[channel] ?? true;
preferences[eventType]![channel] = stored[eventType]?.[channel] ?? fallback === 'on';
}
}
}
@@ -210,7 +277,7 @@ export class NotificationPreferencesService {
channels: this.describeChannels(userId, scope),
event_types,
implemented_combos,
...(scope === 'user' && { defaults: { ntfyServer: this.getAppSetting('admin_ntfy_server') || null } }),
...(scope === 'user' && { defaults: { ntfyServer: this.getAppSetting('admin_ntfy_server') || null }, locked }),
};
}
@@ -249,12 +316,13 @@ export class NotificationPreferencesService {
for (const [eventType, channels] of Object.entries(prefs)) {
if (!channels) continue;
for (const [channel, enabled] of Object.entries(channels)) {
if (enabled) {
// Remove explicit row — default is enabled
del.run(userId, eventType, channel);
} else {
upsert.run(userId, eventType, channel, 0);
}
const fallback = this.instanceDefault(eventType as NotifEventType, channel);
// A cell the admin blocked stays off whatever is sent (#1536).
if (fallback === 'blocked') continue;
// A value that matches the instance default is stored as no row, so a later
// change of the default reaches everybody who never chose otherwise.
if (enabled === (fallback === 'on')) del.run(userId, eventType, channel);
else upsert.run(userId, eventType, channel, enabled ? 1 : 0);
}
}
}
@@ -25,7 +25,7 @@ import {
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { AdminGuard } from '../auth/admin.guard';
import { NotificationPreferencesService } from './notification-preferences.service';
import { AdminNotificationPreferencesDto } from '../admin/admin.dto';
import { AdminNotificationPreferencesDto, NotificationDefaultsUpdateDto } from '../admin/admin.dto';
import { CurrentUser } from '../auth/current-user.decorator';
import { ManagedForbidden } from '../common/managed';
@@ -229,6 +229,18 @@ export class AdminNotificationPreferencesController {
return this.prefs.getPreferencesMatrix(user.id, user.role, 'admin');
}
/** What every user's notification cells start as, and which the admin blocked (#1536). */
@Get('defaults')
getDefaults(@CurrentUser() user: User) {
return this.prefs.getInstanceDefaults(user.id);
}
@Put('defaults')
setDefaults(@CurrentUser() user: User, @Body() body: NotificationDefaultsUpdateDto) {
this.prefs.setInstanceDefaults(body.defaults);
return this.prefs.getInstanceDefaults(user.id);
}
@Put()
set(@CurrentUser() user: User, @Body() body: AdminNotificationPreferencesDto) {
this.prefs.setAdminPreferences(user.id, body);
@@ -16,6 +16,8 @@ function controller() {
const prefs = {
getPreferencesMatrix: vi.fn(() => ({ rows: [{ event: 'trip_reminder' }] })),
setAdminPreferences: vi.fn(),
getInstanceDefaults: vi.fn(() => ({ defaults: { trip_invite: { email: 'off' } } })),
setInstanceDefaults: vi.fn(),
} as unknown as NotificationPreferencesService;
return { c: new AdminNotificationPreferencesController(prefs), prefs };
}
@@ -46,6 +48,14 @@ describe('AdminNotificationPreferencesController', () => {
expect(prefs.getPreferencesMatrix).toHaveBeenCalledWith(7, 'admin', 'admin');
});
it('ADMINPREF-005 the defaults for users are read and written for the acting admin (#1536)', () => {
const { c, prefs } = controller();
expect(c.getDefaults(admin)).toEqual({ defaults: { trip_invite: { email: 'off' } } });
expect(prefs.getInstanceDefaults).toHaveBeenCalledWith(1);
expect(c.setDefaults(admin, { defaults: { trip_invite: { email: 'off' } } } as never)).toEqual({ defaults: { trip_invite: { email: 'off' } } });
expect(prefs.setInstanceDefaults).toHaveBeenCalledWith({ trip_invite: { email: 'off' } });
});
it('ADMINPREF-004 the class is listed in its module controllers', () => {
expectRegisteredController(NotificationsModule, AdminNotificationPreferencesController);
});
@@ -357,3 +357,56 @@ describe('isWebhookConfigured', () => {
expect(isWebhookConfigured()).toBe(true);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// Instance defaults (#1536)
// ─────────────────────────────────────────────────────────────────────────────
describe('instance defaults', () => {
it('NPREF-028 — "off" as the default turns a cell off for everyone who never touched it', () => {
const { user } = createUser(testDb);
svc.setInstanceDefaults({ trip_invite: { email: 'off' } });
expect(isEnabledForEvent(user.id, 'trip_invite', 'email')).toBe(false);
expect(getPreferencesMatrix(user.id, 'user').preferences.trip_invite?.email).toBe(false);
// Other cells keep the old default.
expect(isEnabledForEvent(user.id, 'trip_invite', 'inapp')).toBe(true);
});
it('NPREF-029 — a user may still turn an "off" default on, and that choice is kept', () => {
const { user } = createUser(testDb);
svc.setInstanceDefaults({ trip_invite: { email: 'off' } });
setPreferences(user.id, { trip_invite: { email: true } });
expect(isEnabledForEvent(user.id, 'trip_invite', 'email')).toBe(true);
// Switching back to the default leaves no row behind.
setPreferences(user.id, { trip_invite: { email: false } });
expect(testDb.prepare('SELECT COUNT(*) AS n FROM notification_channel_preferences WHERE user_id = ?').get(user.id)).toEqual({ n: 0 });
});
it('NPREF-030 — a blocked cell is off, locked in the matrix, and a user cannot turn it on', () => {
const { user } = createUser(testDb);
svc.setInstanceDefaults({ booking_change: { email: 'blocked' } });
setPreferences(user.id, { booking_change: { email: true } });
expect(isEnabledForEvent(user.id, 'booking_change', 'email')).toBe(false);
const matrix = getPreferencesMatrix(user.id, 'user');
expect(matrix.preferences.booking_change?.email).toBe(false);
expect(matrix.locked?.booking_change).toEqual(['email']);
});
it('NPREF-031 — admin-scoped events and unknown cells are never touched by defaults', () => {
const { user: admin } = createAdmin(testDb);
svc.setInstanceDefaults({ version_available: { inapp: 'blocked' }, trip_invite: { carrier_pigeon: 'off' } } as never);
expect(isEnabledForEvent(admin.id, 'version_available', 'inapp')).toBe(true);
expect(testDb.prepare("SELECT COUNT(*) AS n FROM app_settings WHERE key LIKE 'notif_default_%'").get()).toEqual({ n: 0 });
});
it('NPREF-032 — the defaults matrix lists user events only, "on" stores nothing', () => {
const { user: admin } = createAdmin(testDb);
svc.setInstanceDefaults({ trip_invite: { email: 'off' } });
const before = svc.getInstanceDefaults(admin.id);
expect(before.event_types).not.toContain('version_available');
expect(before.defaults.trip_invite?.email).toBe('off');
expect(before.defaults.trip_invite?.inapp).toBe('on');
svc.setInstanceDefaults({ trip_invite: { email: 'on' } });
expect(testDb.prepare("SELECT COUNT(*) AS n FROM app_settings WHERE key LIKE 'notif_default_%'").get()).toEqual({ n: 0 });
});
});
+14
View File
@@ -135,6 +135,20 @@ export type AdminCollabFeaturesRequest = z.infer<typeof adminCollabFeaturesReque
export const adminNotificationPreferencesRequestSchema = z.record(z.string(), z.record(z.string(), z.boolean()));
export type AdminNotificationPreferencesRequest = z.infer<typeof adminNotificationPreferencesRequestSchema>;
/**
* What a user's notification cell starts as, set by the admin for the whole
* instance (#1536): `on` (the default), `off` (the user may still turn it on)
* or `blocked` (off for everyone, and the user cannot turn it on).
*/
export const NOTIFICATION_DEFAULTS = ['on', 'off', 'blocked'] as const;
export type NotificationDefault = (typeof NOTIFICATION_DEFAULTS)[number];
/** PUT /api/admin/notification-preferences/defaults: event → channel → default. Cells left out keep theirs. */
export const notificationDefaultsUpdateRequestSchema = z.object({
defaults: z.record(z.string().max(64), z.record(z.string().max(64), z.enum(NOTIFICATION_DEFAULTS))),
});
export type NotificationDefaultsUpdateRequest = z.infer<typeof notificationDefaultsUpdateRequestSchema>;
// Heterogeneous values, and `null` is meaningful — SettingsService treats it as
// "reset to the built-in default". z.record also rejects arrays and null bodies,
// preserving the route's object-only guard.
+6
View File
@@ -39,6 +39,12 @@ const admin: TranslationStrings = {
'admin.notifications.adminNtfyPanel.alwaysOnHint': 'يُرسل Ntfy للمسؤول دائمًا عند تهيئة موضوع',
'admin.notifications.adminNotificationsHint':
'حدد القنوات التي تُسلّم إشعارات المسؤول (مثل تنبيهات الإصدارات). يُرسل الـ Webhook تلقائيًا عند تعيين رابط URL لـ Webhook المسؤول.',
'admin.notificationDefaults.title': 'الإعدادات الافتراضية للمستخدمين',
'admin.notificationDefaults.hint': 'ما تبدأ به إشعارات كل مستخدم. «إيقاف» يمكن للمستخدم تشغيله بنفسه، و«محظور» يوقفه للجميع ويظهر مقفلًا في إعداداتهم. يسري على كل من لم يغيّر الخانة بنفسه.',
'admin.notificationDefaults.on': 'تشغيل',
'admin.notificationDefaults.off': 'إيقاف',
'admin.notificationDefaults.blocked': 'محظور',
'admin.notificationDefaults.cycle': 'انقر للتبديل إلى: {next}',
'admin.notifications.tripReminders.title': 'تذكيرات الرحلات',
'admin.notifications.tripReminders.hint': 'إرسال تذكير قبل بدء الرحلة (يتطلب تعيين أيام التذكير على الرحلة).',
'admin.notifications.tripReminders.enabled': 'تم تفعيل تذكيرات الرحلات',
+1
View File
@@ -300,6 +300,7 @@ const settings: TranslationStrings = {
'settings.notificationPreferences.webhook': 'Webhook', // en-fallback
'settings.notificationPreferences.inapp': 'In-App', // en-fallback
'settings.notificationPreferences.notConfigured': 'لم يتم الإعداد بعد — قم بإعداده من إعدادات الإضافة',
'settings.notificationPreferences.lockedByAdmin': 'أوقفه المسؤول للجميع',
'settings.plugins.actions': 'الإجراءات',
'settings.plugins.actions.confirm': 'تنفيذ هذا الإجراء؟',
'settings.notificationPreferences.sendTest': 'إرسال اختبار',
+6
View File
@@ -52,6 +52,12 @@ const admin: TranslationStrings = {
'Mövzu konfiqurasiya edildikdə administrator ntfy bildirişi həmişə işə düşür',
'admin.notifications.adminNotificationsHint':
'Yalnız administratorlara aid bildirişləri hansı kanalların çatdıracağını konfiqurasiya edin (məsələn, versiya xəbərdarlıqları).',
'admin.notificationDefaults.title': 'İstifadəçilər üçün ilkin dəyərlər',
'admin.notificationDefaults.hint': 'Hər istifadəçinin bildirişləri belə başlayır. "Söndürülüb" istifadəçi özü yandıra bilər, "Bloklanıb" hamı üçün söndürür və onların ayarlarında kilidli görünür. Xananı özü dəyişməyən hər kəsə aiddir.',
'admin.notificationDefaults.on': 'Açıq',
'admin.notificationDefaults.off': 'Söndürülüb',
'admin.notificationDefaults.blocked': 'Bloklanıb',
'admin.notificationDefaults.cycle': 'Dəyişmək üçün klikləyin: {next}',
'admin.notifications.tripReminders.title': 'Səyahət xatırlatmaları',
'admin.notifications.tripReminders.hint':
'Səyahət başlamazdan əvvəl xatırlatma bildirişi göndərin (səyahətdə xatırlatma günlərinin təyin edilməsi tələb olunur).',
+1
View File
@@ -153,6 +153,7 @@ const settings: TranslationStrings = {
'settings.notificationPreferences.inapp': 'Tətbiqdaxili',
'settings.notificationPreferences.notConfigured':
'Hələ konfiqurasiya edilməyib — plaginin tənzimləmələrində quraşdırın',
'settings.notificationPreferences.lockedByAdmin': 'Administrator hamı üçün söndürüb',
'settings.plugins.actions': 'Əməliyyatlar',
'settings.plugins.actions.confirm': 'Bu əməliyyat icra edilsin?',
'settings.notificationPreferences.sendTest': 'Test göndər',
+6
View File
@@ -768,6 +768,12 @@ const admin: TranslationStrings = {
'admin.notifications.adminNtfyPanel.alwaysOnHint': 'O Ntfy de admin sempre dispara quando um tópico está configurado',
'admin.notifications.adminNotificationsHint':
'Configure quais canais entregam notificações de admin (ex. alertas de versão). O webhook dispara automaticamente se uma URL de webhook de admin estiver definida.',
'admin.notificationDefaults.title': 'Padrões para usuários',
'admin.notificationDefaults.hint': 'Como as notificações de cada usuário começam. "Desligado" o usuário ainda pode ligar; "Bloqueado" desliga para todos e aparece trancado nas configurações deles. Vale para quem não alterou a célula.',
'admin.notificationDefaults.on': 'Ligado',
'admin.notificationDefaults.off': 'Desligado',
'admin.notificationDefaults.blocked': 'Bloqueado',
'admin.notificationDefaults.cycle': 'Clique para mudar para: {next}',
'admin.notifications.tripReminders.title': 'Lembretes de viagem',
'admin.notifications.tripReminders.hint':
'Envia uma notificação de lembrete antes do início de uma viagem (requer dias de lembrete definidos na viagem).',
+1
View File
@@ -319,6 +319,7 @@ const settings: TranslationStrings = {
'settings.ntfyUrl.tokenCleared': 'Token de acesso removido',
'settings.notificationPreferences.inapp': 'In-App',
'settings.notificationPreferences.notConfigured': 'Ainda não configurado — configure nas definições do plugin',
'settings.notificationPreferences.lockedByAdmin': 'Desativado pelo administrador para todos',
'settings.plugins.actions': 'Ações',
'settings.plugins.actions.confirm': 'Executar esta ação?',
'settings.notificationPreferences.sendTest': 'Enviar teste',
+6
View File
@@ -412,6 +412,12 @@ const admin: TranslationStrings = {
"El Ntfy d'administrador sempre s'activa quan hi ha un tema configurat",
'admin.notifications.adminNotificationsHint':
"Configura quins canals entreguen notificacions d'administrador (ex. alertes de versió). El webhook s'activa automàticament si hi ha una URL de webhook d'administrador configurada.",
'admin.notificationDefaults.title': 'Valors per defecte dels usuaris',
'admin.notificationDefaults.hint': "Com comencen les notificacions de cada usuari. \"Desactivat\" l'usuari encara el pot activar; \"Bloquejat\" el desactiva per a tothom i apareix bloquejat a la seva configuració. S'aplica a qui no ha canviat la cel·la.",
'admin.notificationDefaults.on': 'Activat',
'admin.notificationDefaults.off': 'Desactivat',
'admin.notificationDefaults.blocked': 'Bloquejat',
'admin.notificationDefaults.cycle': 'Fes clic per canviar a: {next}',
'admin.notifications.tripReminders.title': 'Recordatoris de viatge',
'admin.notifications.tripReminders.hint':
'Envia una notificació de recordatori abans que comenci un viatge (requereix dies de recordatori configurats al viatge).',
+1
View File
@@ -570,6 +570,7 @@ const settings: TranslationStrings = {
'settings.pluginActivity.columns.status': 'Resultat',
'settings.notificationPreferences.notConfigured':
"Encara no s'ha configurat — configura-ho als ajustos del connector",
'settings.notificationPreferences.lockedByAdmin': "Desactivat per l'administrador per a tothom",
'settings.plugins.actions': 'Accions',
'settings.plugins.actions.confirm': 'Vols executar aquesta acció?',
'settings.notificationPreferences.sendTest': 'Envia una prova',
+6
View File
@@ -761,6 +761,12 @@ const admin: TranslationStrings = {
'admin.notifications.adminNtfyPanel.alwaysOnHint': 'Admin Ntfy odesílá vždy, když je nakonfigurováno téma',
'admin.notifications.adminNotificationsHint':
'Nastavte, které kanály doručují admin oznámení (např. upozornění na verze). Webhook odesílá automaticky, pokud je nastavena URL admin webhooku.',
'admin.notificationDefaults.title': 'Výchozí nastavení pro uživatele',
'admin.notificationDefaults.hint': 'Jak začínají oznámení každého uživatele. „Vypnuto“ si uživatel může sám zapnout, „Blokováno“ vypne pro všechny a v jejich nastavení se zobrazí zamčené. Platí pro každého, kdo buňku sám nezměnil.',
'admin.notificationDefaults.on': 'Zapnuto',
'admin.notificationDefaults.off': 'Vypnuto',
'admin.notificationDefaults.blocked': 'Blokováno',
'admin.notificationDefaults.cycle': 'Kliknutím přepnete na: {next}',
'admin.notifications.tripReminders.title': 'Připomínky výletů',
'admin.notifications.tripReminders.hint':
'Odešle upozornění před začátkem výletu (vyžaduje nastavené dny připomínky na výletu).',
+1
View File
@@ -313,6 +313,7 @@ const settings: TranslationStrings = {
'settings.ntfyUrl.tokenCleared': 'Přístupový token byl vymazán',
'settings.notificationPreferences.inapp': 'In-App',
'settings.notificationPreferences.notConfigured': 'Zatím nenastaveno — nastavte to v nastavení pluginu',
'settings.notificationPreferences.lockedByAdmin': 'Vypnuto správcem pro všechny',
'settings.plugins.actions': 'Akce',
'settings.plugins.actions.confirm': 'Spustit tuto akci?',
'settings.notificationPreferences.sendTest': 'Odeslat test',
+6
View File
@@ -775,6 +775,12 @@ const admin: TranslationStrings = {
'admin.notifications.adminNtfyPanel.alwaysOnHint': 'Admin-Ntfy sendet immer, wenn ein Thema konfiguriert ist',
'admin.notifications.adminNotificationsHint':
'Konfiguriere, welche Kanäle Admin-Benachrichtigungen liefern (z. B. Versions-Updates). Der Webhook sendet automatisch, wenn eine Admin-Webhook-URL gesetzt ist.',
'admin.notificationDefaults.title': 'Vorgaben für Nutzer',
'admin.notificationDefaults.hint': 'So starten die Benachrichtigungen jedes Nutzers. „Aus“ kann jeder selbst wieder einschalten, „Gesperrt“ schaltet für alle ab und erscheint in ihren Einstellungen mit Schloss. Gilt für alle, die die Zelle nicht selbst geändert haben.',
'admin.notificationDefaults.on': 'An',
'admin.notificationDefaults.off': 'Aus',
'admin.notificationDefaults.blocked': 'Gesperrt',
'admin.notificationDefaults.cycle': 'Klicken für: {next}',
'admin.notifications.tripReminders.title': 'Reiseerinnerungen',
'admin.notifications.tripReminders.hint':
'Sendet eine Erinnerungsbenachrichtigung vor Reisebeginn (erfordert gesetzte Erinnerungstage bei der Reise).',
+1
View File
@@ -320,6 +320,7 @@ const settings: TranslationStrings = {
'settings.notificationPreferences.inapp': 'In-App',
'settings.notificationPreferences.notConfigured':
'Noch nicht eingerichtet — in den Einstellungen des Plugins konfigurieren',
'settings.notificationPreferences.lockedByAdmin': 'Vom Admin für alle abgeschaltet',
'settings.plugins.actions': 'Aktionen',
'settings.plugins.actions.confirm': 'Diese Aktion ausführen?',
'settings.notificationPreferences.sendTest': 'Test senden',
+6
View File
@@ -49,6 +49,12 @@ const admin: TranslationStrings = {
'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.notificationDefaults.title': 'Defaults for users',
'admin.notificationDefaults.hint': "How every user's notifications start. Off is a default each user may still turn on; Blocked turns it off for everyone and shows as locked in their settings. It applies to everyone who has not changed that cell themselves.",
'admin.notificationDefaults.on': 'On',
'admin.notificationDefaults.off': 'Off',
'admin.notificationDefaults.blocked': 'Blocked',
'admin.notificationDefaults.cycle': 'Click for: {next}',
'admin.notifications.tripReminders.title': 'Trip Reminders',
'admin.notifications.tripReminders.hint':
'Send a reminder notification before a trip starts (requires reminder days to be set on the trip).',
+1
View File
@@ -117,6 +117,7 @@ const settings: TranslationStrings = {
'settings.notificationPreferences.webhook': 'Webhook',
'settings.notificationPreferences.inapp': 'In-App',
'settings.notificationPreferences.notConfigured': 'Not configured yet — set it up in the plugin’s settings',
'settings.notificationPreferences.lockedByAdmin': 'Turned off for everyone by the admin',
'settings.plugins.actions': 'Actions',
'settings.plugins.actions.confirm': 'Run this action?',
'settings.notificationPreferences.sendTest': 'Send test',
+6
View File
@@ -783,6 +783,12 @@ const admin: TranslationStrings = {
'El Ntfy de admin siempre se activa cuando hay un tema configurado',
'admin.notifications.adminNotificationsHint':
'Configura qué canales entregan notificaciones de admin (ej. alertas de versión). El webhook se activa automáticamente si hay una URL de webhook de admin configurada.',
'admin.notificationDefaults.title': 'Valores predeterminados para usuarios',
'admin.notificationDefaults.hint': 'Cómo empiezan las notificaciones de cada usuario. "Desactivado" el usuario aún puede activarlo; "Bloqueado" lo desactiva para todos y aparece bloqueado en su configuración. Se aplica a quien no haya cambiado la celda.',
'admin.notificationDefaults.on': 'Activado',
'admin.notificationDefaults.off': 'Desactivado',
'admin.notificationDefaults.blocked': 'Bloqueado',
'admin.notificationDefaults.cycle': 'Haz clic para cambiar a: {next}',
'admin.notifications.tripReminders.title': 'Recordatorios de viaje',
'admin.notifications.tripReminders.hint':
'Envía una notificación de recordatorio antes de que comience un viaje (requiere días de recordatorio configurados en el viaje).',
+1
View File
@@ -320,6 +320,7 @@ const settings: TranslationStrings = {
'settings.ntfyUrl.tokenCleared': 'Token de acceso eliminado',
'settings.notificationPreferences.inapp': 'In-App',
'settings.notificationPreferences.notConfigured': 'Aún no configurado — configúralo en los ajustes del plugin',
'settings.notificationPreferences.lockedByAdmin': 'Desactivado por el administrador para todos',
'settings.plugins.actions': 'Acciones',
'settings.plugins.actions.confirm': '¿Ejecutar esta acción?',
'settings.notificationPreferences.sendTest': 'Enviar prueba',
+6
View File
@@ -51,6 +51,12 @@ const admin: TranslationStrings = {
'admin.notifications.adminNtfyPanel.alwaysOnHint': 'Administraatori ntfy käivitub alati, kui teema on seadistatud',
'admin.notifications.adminNotificationsHint':
'Määra, millised kanalid edastavad ainult administraatorile mõeldud teavitusi (nt uue versiooni teated).',
'admin.notificationDefaults.title': 'Kasutajate vaikeväärtused',
'admin.notificationDefaults.hint': 'Nii algavad iga kasutaja teavitused. „Väljas“ saab kasutaja ise sisse lülitada, „Blokeeritud“ lülitab kõigile välja ja kuvatakse nende seadetes lukustatuna. Kehtib kõigile, kes pole lahtrit ise muutnud.',
'admin.notificationDefaults.on': 'Sees',
'admin.notificationDefaults.off': 'Väljas',
'admin.notificationDefaults.blocked': 'Blokeeritud',
'admin.notificationDefaults.cycle': 'Klõpsa, et valida: {next}',
'admin.notifications.tripReminders.title': 'Reisi meeldetuletused',
'admin.notifications.tripReminders.hint':
'Saada enne reisi algust meeldetuletus (reisil peab olema määratud, mitu päeva varem meelde tuletada).',
+1
View File
@@ -123,6 +123,7 @@ const settings: TranslationStrings = {
'settings.notificationPreferences.webhook': 'Veebihaak',
'settings.notificationPreferences.inapp': 'Rakenduses',
'settings.notificationPreferences.notConfigured': 'Veel seadistamata — seadista plugina seadetes',
'settings.notificationPreferences.lockedByAdmin': 'Administraator lülitas selle kõigile välja',
'settings.plugins.actions': 'Toimingud',
'settings.plugins.actions.confirm': 'Kas käivitada see toiming?',
'settings.notificationPreferences.sendTest': 'Saada test',
+6
View File
@@ -782,6 +782,12 @@ const admin: TranslationStrings = {
'admin.notifications.adminNtfyPanel.alwaysOnHint': "Le Ntfy admin s'active toujours lorsqu'un sujet est configuré",
'admin.notifications.adminNotificationsHint':
"Configurez quels canaux envoient les notifications admin (ex. alertes de version). Le webhook s'active automatiquement si une URL webhook admin est définie.",
'admin.notificationDefaults.title': 'Valeurs par défaut des utilisateurs',
'admin.notificationDefaults.hint': "L'état de départ des notifications de chaque utilisateur. « Désactivé » peut encore être activé par l'utilisateur ; « Bloqué » le désactive pour tous et apparaît verrouillé dans leurs réglages. S'applique à ceux qui n'ont pas modifié la case.",
'admin.notificationDefaults.on': 'Activé',
'admin.notificationDefaults.off': 'Désactivé',
'admin.notificationDefaults.blocked': 'Bloqué',
'admin.notificationDefaults.cycle': 'Cliquer pour : {next}',
'admin.notifications.tripReminders.title': 'Rappels de voyage',
'admin.notifications.tripReminders.hint':
"Envoie une notification de rappel avant le début d'un voyage (nécessite des jours de rappel définis sur le voyage).",
+1
View File
@@ -324,6 +324,7 @@ const settings: TranslationStrings = {
'settings.ntfyUrl.tokenCleared': "Jeton d'accès effacé",
'settings.notificationPreferences.inapp': 'In-App',
'settings.notificationPreferences.notConfigured': 'Pas encore configuré — à paramétrer dans les réglages du plugin',
'settings.notificationPreferences.lockedByAdmin': "Désactivé pour tout le monde par l'administrateur",
'settings.plugins.actions': 'Actions',
'settings.plugins.actions.confirm': 'Exécuter cette action ?',
'settings.notificationPreferences.sendTest': 'Envoyer un test',
+6
View File
@@ -52,6 +52,12 @@ const admin: TranslationStrings = {
'Το ntfy διαχειριστή ενεργοποιείται πάντα όταν έχει διαμορφωθεί ένα θέμα',
'admin.notifications.adminNotificationsHint':
'Διαμορφώστε ποια κανάλια παραδίδουν ειδοποιήσεις μόνο για διαχειριστές (π.χ. ειδοποιήσεις έκδοσης).',
'admin.notificationDefaults.title': 'Προεπιλογές για χρήστες',
'admin.notificationDefaults.hint': 'Πώς ξεκινούν οι ειδοποιήσεις κάθε χρήστη. Το «Ανενεργό» μπορεί να το ενεργοποιήσει ο ίδιος, το «Αποκλεισμένο» το απενεργοποιεί για όλους και εμφανίζεται κλειδωμένο στις ρυθμίσεις τους. Ισχύει για όσους δεν άλλαξαν το κελί.',
'admin.notificationDefaults.on': 'Ενεργό',
'admin.notificationDefaults.off': 'Ανενεργό',
'admin.notificationDefaults.blocked': 'Αποκλεισμένο',
'admin.notificationDefaults.cycle': 'Κλικ για: {next}',
'admin.notifications.tripReminders.title': 'Υπενθυμίσεις Ταξιδιού',
'admin.notifications.tripReminders.hint':
'Αποστολή ειδοποίησης υπενθύμισης πριν την έναρξη ενός ταξιδιού (απαιτεί να έχουν οριστεί ημέρες υπενθύμισης στο ταξίδι).',
+1
View File
@@ -114,6 +114,7 @@ const settings: TranslationStrings = {
'settings.notificationPreferences.inapp': 'Εντός εφαρμογής',
'settings.notificationPreferences.notConfigured':
'Δεν έχει ρυθμιστεί ακόμη — ρυθμίστε το στις ρυθμίσεις του πρόσθετου',
'settings.notificationPreferences.lockedByAdmin': 'Απενεργοποιήθηκε από τον διαχειριστή για όλους',
'settings.plugins.actions': 'Ενέργειες',
'settings.plugins.actions.confirm': 'Εκτέλεση αυτής της ενέργειας;',
'settings.notificationPreferences.sendTest': 'Αποστολή δοκιμής',
+6
View File
@@ -781,6 +781,12 @@ const admin: TranslationStrings = {
'admin.notifications.adminNtfyPanel.alwaysOnHint': 'Az admin Ntfy mindig küld, ha egy téma konfigurálva van',
'admin.notifications.adminNotificationsHint':
'Állítsa be, hogy mely csatornák szállítsák az admin értesítéseket (pl. verziófrissítési figyelmeztetések). A webhook automatikusan küld, ha admin webhook URL van megadva.',
'admin.notificationDefaults.title': 'Alapértelmezések a felhasználóknak',
'admin.notificationDefaults.hint': 'Így indulnak a felhasználók értesítései. A „Ki” állapotot bárki visszakapcsolhatja, a „Tiltva” mindenkinek kikapcsolja, és zárolva jelenik meg a beállításaikban. Mindenkire vonatkozik, aki nem módosította a cellát.',
'admin.notificationDefaults.on': 'Be',
'admin.notificationDefaults.off': 'Ki',
'admin.notificationDefaults.blocked': 'Tiltva',
'admin.notificationDefaults.cycle': 'Kattints: {next}',
'admin.notifications.tripReminders.title': 'Utazási emlékeztetők',
'admin.notifications.tripReminders.hint':
'Emlékeztető értesítést küld az utazás kezdete előtt (az utazásnál megadott emlékeztető napok szükségesek).',
+1
View File
@@ -320,6 +320,7 @@ const settings: TranslationStrings = {
'settings.ntfyUrl.tokenCleared': 'Hozzáférési token törölve',
'settings.notificationPreferences.inapp': 'In-App',
'settings.notificationPreferences.notConfigured': 'Még nincs beállítva — állítsd be a bővítmény beállításaiban',
'settings.notificationPreferences.lockedByAdmin': 'A rendszergazda mindenkinek kikapcsolta',
'settings.plugins.actions': 'Műveletek',
'settings.plugins.actions.confirm': 'Végrehajtod ezt a műveletet?',
'settings.notificationPreferences.sendTest': 'Teszt küldése',
+6
View File
@@ -49,6 +49,12 @@ const admin: TranslationStrings = {
'admin.notifications.adminNtfyPanel.alwaysOnHint': 'Admin Ntfy selalu berjalan jika topik dikonfigurasi',
'admin.notifications.adminNotificationsHint':
'Atur saluran mana yang mengirimkan notifikasi khusus admin (mis. peringatan versi).',
'admin.notificationDefaults.title': 'Bawaan untuk pengguna',
'admin.notificationDefaults.hint': 'Keadaan awal notifikasi setiap pengguna. "Mati" masih bisa dinyalakan pengguna; "Diblokir" mematikan untuk semua orang dan tampil terkunci di pengaturan mereka. Berlaku bagi yang belum mengubah sel itu.',
'admin.notificationDefaults.on': 'Nyala',
'admin.notificationDefaults.off': 'Mati',
'admin.notificationDefaults.blocked': 'Diblokir',
'admin.notificationDefaults.cycle': 'Klik untuk: {next}',
'admin.notifications.tripReminders.title': 'Pengingat Perjalanan',
'admin.notifications.tripReminders.hint':
'Mengirim notifikasi pengingat sebelum perjalanan dimulai (memerlukan hari pengingat yang diatur pada perjalanan).',
+1
View File
@@ -104,6 +104,7 @@ const settings: TranslationStrings = {
'settings.notificationPreferences.webhook': 'Webhook',
'settings.notificationPreferences.inapp': 'In-App',
'settings.notificationPreferences.notConfigured': 'Belum dikonfigurasi — atur di pengaturan plugin',
'settings.notificationPreferences.lockedByAdmin': 'Dimatikan admin untuk semua orang',
'settings.plugins.actions': 'Tindakan',
'settings.plugins.actions.confirm': 'Jalankan tindakan ini?',
'settings.notificationPreferences.sendTest': 'Kirim tes',
+6
View File
@@ -777,6 +777,12 @@ const admin: TranslationStrings = {
'admin.notifications.adminNtfyPanel.alwaysOnHint': 'Il Ntfy admin si attiva sempre quando un argomento è configurato',
'admin.notifications.adminNotificationsHint':
'Configura quali canali consegnano le notifiche admin (es. avvisi di versione). Il webhook si attiva automaticamente se è impostato un URL webhook admin.',
'admin.notificationDefaults.title': 'Impostazioni predefinite per gli utenti',
'admin.notificationDefaults.hint': "Come partono le notifiche di ogni utente. \"Disattivato\" l'utente può ancora attivarlo; \"Bloccato\" lo spegne per tutti e appare bloccato nelle loro impostazioni. Vale per chi non ha cambiato la cella.",
'admin.notificationDefaults.on': 'Attivo',
'admin.notificationDefaults.off': 'Disattivato',
'admin.notificationDefaults.blocked': 'Bloccato',
'admin.notificationDefaults.cycle': 'Clic per: {next}',
'admin.notifications.tripReminders.title': 'Promemoria viaggio',
'admin.notifications.tripReminders.hint':
"Invia una notifica promemoria prima dell'inizio di un viaggio (richiede giorni di promemoria impostati sul viaggio).",
+1
View File
@@ -319,6 +319,7 @@ const settings: TranslationStrings = {
'settings.ntfyUrl.tokenCleared': 'Token di accesso rimosso',
'settings.notificationPreferences.inapp': 'In-App',
'settings.notificationPreferences.notConfigured': 'Non ancora configurato — impostalo nelle impostazioni del plugin',
'settings.notificationPreferences.lockedByAdmin': "Disattivato dall'amministratore per tutti",
'settings.plugins.actions': 'Azioni',
'settings.plugins.actions.confirm': 'Eseguire questa azione?',
'settings.notificationPreferences.sendTest': 'Invia test',
+6
View File
@@ -44,6 +44,12 @@ const admin: TranslationStrings = {
'admin.notifications.adminNtfyPanel.testFailed': 'テストntfyに失敗しました',
'admin.notifications.adminNtfyPanel.alwaysOnHint': 'トピック設定時は常に送信されます',
'admin.notifications.adminNotificationsHint': '管理者専用通知の配信先を設定します。',
'admin.notificationDefaults.title': 'ユーザーの既定値',
'admin.notificationDefaults.hint': '各ユーザーの通知の初期状態です。「オフ」はユーザー自身でオンにでき、「ブロック」は全員に対してオフになり、設定画面でロック表示されます。そのセルを自分で変更していない全員に適用されます。',
'admin.notificationDefaults.on': 'オン',
'admin.notificationDefaults.off': 'オフ',
'admin.notificationDefaults.blocked': 'ブロック',
'admin.notificationDefaults.cycle': 'クリックで切替: {next}',
'admin.notifications.tripReminders.title': '旅行リマインダー',
'admin.notifications.tripReminders.hint': '旅行開始前に通知を送信します(旅行側の設定が必要)。',
'admin.notifications.tripReminders.enabled': '旅行リマインダー有効',
+1
View File
@@ -105,6 +105,7 @@ const settings: TranslationStrings = {
'settings.notificationPreferences.webhook': 'Webhook',
'settings.notificationPreferences.inapp': 'アプリ内',
'settings.notificationPreferences.notConfigured': 'まだ設定されていません — プラグインの設定で構成してください',
'settings.notificationPreferences.lockedByAdmin': '管理者が全員に対してオフにしています',
'settings.plugins.actions': 'アクション',
'settings.plugins.actions.confirm': 'このアクションを実行しますか?',
'settings.notificationPreferences.sendTest': 'テスト送信',
+6
View File
@@ -48,6 +48,12 @@ const admin: TranslationStrings = {
'admin.notifications.adminNtfyPanel.testFailed': '테스트 ntfy 실패',
'admin.notifications.adminNtfyPanel.alwaysOnHint': '토픽이 설정되면 관리자 ntfy는 항상 실행됩니다',
'admin.notifications.adminNotificationsHint': '관리자 전용 알림 (예: 버전 알림)을 전달할 채널을 설정하세요.',
'admin.notificationDefaults.title': '사용자 기본값',
'admin.notificationDefaults.hint': '각 사용자의 알림 초기 상태입니다. "끔"은 사용자가 직접 켤 수 있고, "차단"은 모두에게 꺼지며 설정에서 잠금으로 표시됩니다. 해당 칸을 직접 바꾸지 않은 모든 사용자에게 적용됩니다.',
'admin.notificationDefaults.on': '켬',
'admin.notificationDefaults.off': '끔',
'admin.notificationDefaults.blocked': '차단',
'admin.notificationDefaults.cycle': '클릭하여 변경: {next}',
'admin.notifications.tripReminders.title': '여행 리마인더',
'admin.notifications.tripReminders.hint':
'여행 시작 전에 리마인더 알림을 전송합니다 (여행에 리마인더 일수가 설정되어 있어야 합니다).',
+1
View File
@@ -106,6 +106,7 @@ const settings: TranslationStrings = {
'settings.notificationPreferences.webhook': '웹훅',
'settings.notificationPreferences.inapp': '앱 내',
'settings.notificationPreferences.notConfigured': '아직 설정되지 않았습니다 — 플러그인 설정에서 구성하세요',
'settings.notificationPreferences.lockedByAdmin': '관리자가 모두에게 끔',
'settings.plugins.actions': '작업',
'settings.plugins.actions.confirm': '이 작업을 실행할까요?',
'settings.notificationPreferences.sendTest': '테스트 전송',
+6
View File
@@ -774,6 +774,12 @@ const admin: TranslationStrings = {
'Admin-Ntfy verstuurt altijd wanneer een onderwerp is geconfigureerd',
'admin.notifications.adminNotificationsHint':
'Stel in via welke kanalen admin-meldingen worden bezorgd (bijv. versie-updates). De webhook verstuurt automatisch als er een admin-webhook-URL is ingesteld.',
'admin.notificationDefaults.title': 'Standaarden voor gebruikers',
'admin.notificationDefaults.hint': 'Zo beginnen de meldingen van elke gebruiker. "Uit" kan iedereen zelf weer aanzetten; "Geblokkeerd" zet het voor iedereen uit en staat vergrendeld in hun instellingen. Geldt voor iedereen die de cel niet zelf heeft gewijzigd.',
'admin.notificationDefaults.on': 'Aan',
'admin.notificationDefaults.off': 'Uit',
'admin.notificationDefaults.blocked': 'Geblokkeerd',
'admin.notificationDefaults.cycle': 'Klik voor: {next}',
'admin.notifications.tripReminders.title': 'Reisherinneringen',
'admin.notifications.tripReminders.hint':
'Stuurt een herinneringsmelding voor de start van een reis (vereist ingestelde herinneringsdagen bij de reis).',
+1
View File
@@ -319,6 +319,7 @@ const settings: TranslationStrings = {
'settings.notificationPreferences.inapp': 'In-App',
'settings.notificationPreferences.notConfigured':
'Nog niet ingesteld — configureer het in de instellingen van de plug-in',
'settings.notificationPreferences.lockedByAdmin': 'Door de beheerder voor iedereen uitgeschakeld',
'settings.plugins.actions': 'Acties',
'settings.plugins.actions.confirm': 'Deze actie uitvoeren?',
'settings.notificationPreferences.sendTest': 'Test versturen',
+6
View File
@@ -781,6 +781,12 @@ const admin: TranslationStrings = {
'admin.notifications.adminNtfyPanel.alwaysOnHint': 'Admin Ntfy zawsze wysyła po skonfigurowaniu tematu',
'admin.notifications.adminNotificationsHint':
'Skonfiguruj, które kanały dostarczają powiadomienia admina (np. alerty o wersjach). Webhook wysyła automatycznie, gdy ustawiony jest URL webhooka admina.',
'admin.notificationDefaults.title': 'Ustawienia domyślne dla użytkowników',
'admin.notificationDefaults.hint': 'Tak zaczynają się powiadomienia każdego użytkownika. „Wył.” użytkownik może sam włączyć, „Zablokowane” wyłącza dla wszystkich i jest zablokowane w ich ustawieniach. Dotyczy każdego, kto sam nie zmienił komórki.',
'admin.notificationDefaults.on': 'Wł.',
'admin.notificationDefaults.off': 'Wył.',
'admin.notificationDefaults.blocked': 'Zablokowane',
'admin.notificationDefaults.cycle': 'Kliknij, aby ustawić: {next}',
'admin.notifications.tripReminders.title': 'Przypomnienia o podróżach',
'admin.notifications.tripReminders.hint':
'Wysyła powiadomienie z przypomnieniem przed rozpoczęciem podróży (wymaga ustawienia dni przypomnienia dla podróży).',
+1
View File
@@ -313,6 +313,7 @@ const settings: TranslationStrings = {
'settings.ntfyUrl.tokenCleared': 'Token dostępu wyczyszczony',
'settings.notificationPreferences.inapp': 'In-App',
'settings.notificationPreferences.notConfigured': 'Jeszcze nieskonfigurowane — ustaw to w ustawieniach wtyczki',
'settings.notificationPreferences.lockedByAdmin': 'Wyłączone przez administratora dla wszystkich',
'settings.plugins.actions': 'Akcje',
'settings.plugins.actions.confirm': 'Wykonać tę akcję?',
'settings.notificationPreferences.sendTest': 'Wyślij test',
+6
View File
@@ -776,6 +776,12 @@ const admin: TranslationStrings = {
'Ntfy администратора всегда отправляется при наличии настроенной темы',
'admin.notifications.adminNotificationsHint':
'Настройте, какие каналы доставляют уведомления администратора (например, оповещения о версиях). Вебхук отправляется автоматически, если задан URL вебхука администратора.',
'admin.notificationDefaults.title': 'Значения по умолчанию для пользователей',
'admin.notificationDefaults.hint': 'Так начинаются уведомления каждого пользователя. «Выкл.» пользователь может включить сам, «Заблокировано» отключает для всех и отображается с замком в их настройках. Действует для всех, кто не менял ячейку сам.',
'admin.notificationDefaults.on': 'Вкл.',
'admin.notificationDefaults.off': 'Выкл.',
'admin.notificationDefaults.blocked': 'Заблокировано',
'admin.notificationDefaults.cycle': 'Нажмите для: {next}',
'admin.notifications.tripReminders.title': 'Напоминания о поездках',
'admin.notifications.tripReminders.hint':
'Отправляет напоминание перед началом поездки (необходимо указать дни напоминания в параметрах поездки).',
+1
View File
@@ -319,6 +319,7 @@ const settings: TranslationStrings = {
'settings.ntfyUrl.tokenCleared': 'Токен доступа очищен',
'settings.notificationPreferences.inapp': 'In-App',
'settings.notificationPreferences.notConfigured': 'Ещё не настроено — настройте в параметрах плагина',
'settings.notificationPreferences.lockedByAdmin': 'Отключено администратором для всех',
'settings.plugins.actions': 'Действия',
'settings.plugins.actions.confirm': 'Выполнить это действие?',
'settings.notificationPreferences.sendTest': 'Отправить тест',
+6
View File
@@ -49,6 +49,12 @@ const admin: TranslationStrings = {
'admin.notifications.adminNtfyPanel.alwaysOnHint': 'Admin ntfy sa spustí vždy, keď je nastavená téma',
'admin.notifications.adminNotificationsHint':
'Nastavte, ktoré kanály doručujú oznámenia len pre administrátorov (napr. upozornenia na verzie).',
'admin.notificationDefaults.title': 'Predvolené nastavenia pre používateľov',
'admin.notificationDefaults.hint': 'Takto začínajú upozornenia každého používateľa. „Vypnuté“ si používateľ môže sám zapnúť, „Blokované“ vypne pre všetkých a v ich nastaveniach sa zobrazí zamknuté. Platí pre každého, kto bunku sám nezmenil.',
'admin.notificationDefaults.on': 'Zapnuté',
'admin.notificationDefaults.off': 'Vypnuté',
'admin.notificationDefaults.blocked': 'Blokované',
'admin.notificationDefaults.cycle': 'Kliknutím prepnete na: {next}',
'admin.notifications.tripReminders.title': 'Pripomienky ciest',
'admin.notifications.tripReminders.hint':
'Odoslať pripomienkové oznámenie pred začiatkom cesty (vyžaduje nastavené dni pripomienky na ceste).',
+1
View File
@@ -119,6 +119,7 @@ const settings: TranslationStrings = {
'settings.notificationPreferences.webhook': 'Webhook',
'settings.notificationPreferences.inapp': 'V aplikácii',
'settings.notificationPreferences.notConfigured': 'Zatiaľ nenastavené — nastavte to v nastaveniach doplnku',
'settings.notificationPreferences.lockedByAdmin': 'Vypnuté správcom pre všetkých',
'settings.plugins.actions': 'Akcie',
'settings.plugins.actions.confirm': 'Spustiť túto akciu?',
'settings.notificationPreferences.sendTest': 'Odoslať test',
+6
View File
@@ -50,6 +50,12 @@ const admin: TranslationStrings = {
'admin.notifications.adminNtfyPanel.alwaysOnHint': 'Admin ntfy utlöses alltid när ett ämne konfigureras',
'admin.notifications.adminNotificationsHint':
'Ställ in vilka kanaler som ska skicka meddelanden som endast är avsedda för administratörer (t.ex. versionsvarningar).',
'admin.notificationDefaults.title': 'Standardval för användare',
'admin.notificationDefaults.hint': 'Så börjar varje användares aviseringar. "Av" kan användaren själv slå på; "Blockerad" stänger av för alla och visas låst i deras inställningar. Gäller alla som inte själva ändrat rutan.',
'admin.notificationDefaults.on': 'På',
'admin.notificationDefaults.off': 'Av',
'admin.notificationDefaults.blocked': 'Blockerad',
'admin.notificationDefaults.cycle': 'Klicka för: {next}',
'admin.notifications.tripReminders.title': 'Påminnelser inför resan',
'admin.notifications.tripReminders.hint':
'Skicka en påminnelse innan resan börjar (kräver att påminnelsedagar har angetts för resan).',
+1
View File
@@ -111,6 +111,7 @@ const settings: TranslationStrings = {
'settings.notificationPreferences.webhook': 'Webhook',
'settings.notificationPreferences.inapp': 'I appen',
'settings.notificationPreferences.notConfigured': 'Inte konfigurerat än — ställ in det i pluginets inställningar',
'settings.notificationPreferences.lockedByAdmin': 'Avstängt för alla av administratören',
'settings.plugins.actions': 'Åtgärder',
'settings.plugins.actions.confirm': 'Kör den här åtgärden?',
'settings.notificationPreferences.sendTest': 'Skicka test',
+6
View File
@@ -49,6 +49,12 @@ const admin: TranslationStrings = {
'admin.notifications.adminNtfyPanel.alwaysOnHint': "ผู้ดูแลระบบ ntfy จะเริ่มทำงานเสมอเมื่อมีการกำหนดค่าหัวข้อ",
'admin.notifications.adminNotificationsHint':
"กำหนดค่าช่องทางที่จะส่งการแจ้งเตือนเฉพาะผู้ดูแลระบบเท่านั้น (เช่น การแจ้งเตือนเวอร์ชัน)",
'admin.notificationDefaults.title': 'ค่าเริ่มต้นสำหรับผู้ใช้',
'admin.notificationDefaults.hint': 'สถานะเริ่มต้นของการแจ้งเตือนของผู้ใช้แต่ละคน "ปิด" ผู้ใช้ยังเปิดเองได้ "บล็อก" จะปิดสำหรับทุกคนและแสดงเป็นล็อกในการตั้งค่าของพวกเขา ใช้กับทุกคนที่ยังไม่ได้เปลี่ยนช่องนั้นเอง',
'admin.notificationDefaults.on': 'เปิด',
'admin.notificationDefaults.off': 'ปิด',
'admin.notificationDefaults.blocked': 'บล็อก',
'admin.notificationDefaults.cycle': 'คลิกเพื่อเปลี่ยนเป็น: {next}',
'admin.notifications.tripReminders.title': "การแจ้งเตือนการเดินทาง",
'admin.notifications.tripReminders.hint':
"ส่งการแจ้งเตือนก่อนที่การเดินทางจะเริ่มต้น (ต้องตั้งค่าวันเตือนความจำในการเดินทาง)",
+1
View File
@@ -108,6 +108,7 @@ const settings: TranslationStrings = {
'settings.notificationPreferences.webhook': "เว็บฮุค",
'settings.notificationPreferences.inapp': "ในแอป",
'settings.notificationPreferences.notConfigured': "ยังไม่ได้กำหนดค่า — ตั้งค่าในการตั้งค่าปลั๊กอิน",
'settings.notificationPreferences.lockedByAdmin': 'ผู้ดูแลระบบปิดไว้สำหรับทุกคน',
'settings.plugins.actions': "การดำเนินการ",
'settings.plugins.actions.confirm': "ดำเนินการนี้หรือไม่?",
'settings.notificationPreferences.sendTest': "ส่งแบบทดสอบ",
+6
View File
@@ -49,6 +49,12 @@ const admin: TranslationStrings = {
'admin.notifications.adminNtfyPanel.alwaysOnHint': 'Konu yapılandırıldığında yönetici ntfy her zaman tetiklenir',
'admin.notifications.adminNotificationsHint':
'Yalnızca yönetici bildirimlerinin hangi kanallardan iletileceğini yapılandırın.',
'admin.notificationDefaults.title': 'Kullanıcılar için varsayılanlar',
'admin.notificationDefaults.hint': 'Her kullanıcının bildirimlerinin başlangıç durumu. "Kapalı"yı kullanıcı kendisi açabilir; "Engelli" herkes için kapatır ve ayarlarında kilitli görünür. Hücreyi kendisi değiştirmemiş herkes için geçerlidir.',
'admin.notificationDefaults.on': 'Açık',
'admin.notificationDefaults.off': 'Kapalı',
'admin.notificationDefaults.blocked': 'Engelli',
'admin.notificationDefaults.cycle': 'Değiştirmek için tıklayın: {next}',
'admin.notifications.tripReminders.title': 'Seyahat Hatırlatıcıları',
'admin.notifications.tripReminders.hint':
'Seyahat başlamadan önce hatırlatma gönderir (seyahatte hatırlatma günü ayarlı olmalıdır).',
+1
View File
@@ -107,6 +107,7 @@ const settings: TranslationStrings = {
'settings.notificationPreferences.webhook': 'Web kancası',
'settings.notificationPreferences.inapp': 'Uygulama içi',
'settings.notificationPreferences.notConfigured': 'Henüz yapılandırılmadı — eklentinin ayarlarından yapılandırın',
'settings.notificationPreferences.lockedByAdmin': 'Yönetici tarafından herkes için kapatıldı',
'settings.plugins.actions': 'Eylemler',
'settings.plugins.actions.confirm': 'Bu eylem çalıştırılsın mı?',
'settings.notificationPreferences.sendTest': 'Test gönder',
+6
View File
@@ -772,6 +772,12 @@ const admin: TranslationStrings = {
'Ntfy адміністратора завжди надсилається при наявності налаштованої теми',
'admin.notifications.adminNotificationsHint':
'Налаштуйте, які канали доставляють сповіщення адміністратора (наприклад, повідомлення про версії). Вебхук надсилається автоматично, якщо задано URL вебхука адміністратора.',
'admin.notificationDefaults.title': 'Типові налаштування для користувачів',
'admin.notificationDefaults.hint': 'Так починаються сповіщення кожного користувача. «Вимк.» користувач може ввімкнути сам, «Заблоковано» вимикає для всіх і показується із замком у їхніх налаштуваннях. Діє для всіх, хто не змінював клітинку сам.',
'admin.notificationDefaults.on': 'Увімк.',
'admin.notificationDefaults.off': 'Вимк.',
'admin.notificationDefaults.blocked': 'Заблоковано',
'admin.notificationDefaults.cycle': 'Натисніть для: {next}',
'admin.notifications.tripReminders.title': 'Нагадування про поїздки',
'admin.notifications.tripReminders.hint':
'Надсилає нагадування перед початком поїздки (необхідно вказати дні нагадування в параметрах поїздки).',
+1
View File
@@ -313,6 +313,7 @@ const settings: TranslationStrings = {
'settings.ntfyUrl.tokenCleared': 'Токен доступу очищено',
'settings.notificationPreferences.inapp': 'In-App',
'settings.notificationPreferences.notConfigured': 'Ще не налаштовано — налаштуйте в параметрах плагіна',
'settings.notificationPreferences.lockedByAdmin': 'Вимкнено адміністратором для всіх',
'settings.plugins.actions': 'Дії',
'settings.plugins.actions.confirm': 'Виконати цю дію?',
'settings.notificationPreferences.sendTest': 'Надіслати тест',
+6
View File
@@ -49,6 +49,12 @@ const admin: TranslationStrings = {
'admin.notifications.adminNtfyPanel.alwaysOnHint': 'Quản trị viên ntfy luôn kích hoạt khi chủ đề được định cấu hình',
'admin.notifications.adminNotificationsHint':
'Định cấu hình kênh nào gửi thông báo chỉ dành cho quản trị viên (ví dụ: cảnh báo phiên bản).',
'admin.notificationDefaults.title': 'Mặc định cho người dùng',
'admin.notificationDefaults.hint': 'Trạng thái ban đầu của thông báo cho mỗi người dùng. "Tắt" người dùng vẫn có thể tự bật; "Chặn" tắt cho mọi người và hiển thị khóa trong cài đặt của họ. Áp dụng cho mọi người chưa tự đổi ô đó.',
'admin.notificationDefaults.on': 'Bật',
'admin.notificationDefaults.off': 'Tắt',
'admin.notificationDefaults.blocked': 'Chặn',
'admin.notificationDefaults.cycle': 'Nhấn để chuyển sang: {next}',
'admin.notifications.tripReminders.title': 'Lời nhắc chuyến đi',
'admin.notifications.tripReminders.hint':
'Gửi thông báo nhắc nhở trước khi chuyến đi bắt đầu (yêu cầu phải đặt ngày nhắc nhở cho chuyến đi).',
+1
View File
@@ -112,6 +112,7 @@ const settings: TranslationStrings = {
'settings.notificationPreferences.webhook': 'Webhook',
'settings.notificationPreferences.inapp': 'Trong ứng dụng',
'settings.notificationPreferences.notConfigured': 'Chưa được thiết lập — hãy cấu hình trong cài đặt của plugin',
'settings.notificationPreferences.lockedByAdmin': 'Quản trị viên đã tắt cho mọi người',
'settings.plugins.actions': 'Hành động',
'settings.plugins.actions.confirm': 'Chạy hành động này?',
'settings.notificationPreferences.sendTest': 'Gửi thử',
+6
View File
@@ -47,6 +47,12 @@ const admin: TranslationStrings = {
'admin.notifications.adminNtfyPanel.testFailed': '測試 Ntfy 失敗',
'admin.notifications.adminNtfyPanel.alwaysOnHint': '設定主題後管理員 Ntfy 始終觸發',
'admin.notifications.adminNotificationsHint': '配置哪些渠道傳遞僅管理員通知(例如版本提醒)。',
'admin.notificationDefaults.title': '使用者預設值',
'admin.notificationDefaults.hint': '每位使用者通知的初始狀態。「關閉」使用者仍可自行開啟;「封鎖」會為所有人關閉,並在其設定中顯示為鎖定。適用於尚未自行變更該欄位的所有人。',
'admin.notificationDefaults.on': '開啟',
'admin.notificationDefaults.off': '關閉',
'admin.notificationDefaults.blocked': '封鎖',
'admin.notificationDefaults.cycle': '點擊切換為:{next}',
'admin.notifications.tripReminders.title': '行程提醒',
'admin.notifications.tripReminders.hint': '在行程開始前發送提醒通知(需要在行程中設定提醒天數)。',
'admin.notifications.tripReminders.enabled': '行程提醒已啟用',
+1
View File
@@ -101,6 +101,7 @@ const settings: TranslationStrings = {
'settings.notificationPreferences.webhook': 'Webhook',
'settings.notificationPreferences.inapp': '應用程式內',
'settings.notificationPreferences.notConfigured': '尚未設定 — 請在外掛設定中進行設定',
'settings.notificationPreferences.lockedByAdmin': '管理員已為所有人關閉',
'settings.plugins.actions': '操作',
'settings.plugins.actions.confirm': '執行此操作?',
'settings.notificationPreferences.sendTest': '傳送測試',
+6
View File
@@ -719,6 +719,12 @@ const admin: TranslationStrings = {
'admin.notifications.adminNtfyPanel.alwaysOnHint': '配置主题后管理员 Ntfy 始终触发',
'admin.notifications.adminNotificationsHint':
'配置哪些渠道发送管理员通知(如版本更新提醒)。设置管理员 Webhook URL 后,Webhook 将自动触发。',
'admin.notificationDefaults.title': '用户默认值',
'admin.notificationDefaults.hint': '每位用户通知的初始状态。“关闭”用户仍可自行开启;“封锁”会为所有人关闭,并在其设置中显示为锁定。适用于尚未自行更改该单元格的所有人。',
'admin.notificationDefaults.on': '开启',
'admin.notificationDefaults.off': '关闭',
'admin.notificationDefaults.blocked': '封锁',
'admin.notificationDefaults.cycle': '点击切换为:{next}',
'admin.notifications.tripReminders.title': '行程提醒',
'admin.notifications.tripReminders.hint': '在行程开始前发送提醒通知(需要在行程中设置提醒天数)。',
'admin.notifications.tripReminders.enabled': '行程提醒已启用',
+1
View File
@@ -299,6 +299,7 @@ const settings: TranslationStrings = {
'settings.ntfyUrl.tokenCleared': '访问令牌已清除',
'settings.notificationPreferences.inapp': '应用内',
'settings.notificationPreferences.notConfigured': '尚未配置 — 请在插件设置中进行设置',
'settings.notificationPreferences.lockedByAdmin': '管理员已为所有人关闭',
'settings.plugins.actions': '操作',
'settings.plugins.actions.confirm': '执行此操作?',
'settings.notificationPreferences.sendTest': '发送测试',