diff --git a/client/src/api/client.ts b/client/src/api/client.ts index d9371a758..b3f05d041 100644 --- a/client/src/api/client.ts +++ b/client/src/api/client.ts @@ -508,6 +508,13 @@ export const adminApi = { // Dev-link (dev-only): register a plugin from a local built dir + hot-reload it. pluginLink: (path: string) => apiClient.post('/admin/plugins/link', { path }).then(r => r.data), pluginReload: (id: string) => apiClient.post(`/admin/plugins/${id}/reload`).then(r => r.data), + // Operator-supplied egress hosts: a plugin talking to a SELF-HOSTED service can't name + // the operator's hostname in its manifest, so the admin adds it here. Saving re-spawns + // the plugin with the widened allow-list. + pluginEgressHosts: (id: string): Promise<{ supported: boolean; hosts: string[] }> => + apiClient.get(`/admin/plugins/${id}/egress-hosts`).then(r => r.data), + pluginSetEgressHosts: (id: string, hosts: string[]): Promise<{ hosts: string[] }> => + apiClient.put(`/admin/plugins/${id}/egress-hosts`, { hosts }).then(r => r.data), pluginErrors: (id: string) => apiClient.get(`/admin/plugins/${id}/errors`).then(r => r.data), pluginAudit: (id: string) => apiClient.get(`/admin/plugins/${id}/audit`).then(r => r.data), // Local LLM (Ollama) management for the AI-parsing addon. @@ -632,6 +639,11 @@ export interface PluginUserSettingField { options?: Array<{ value: string; label: string }> } +/** A button a plugin contributes to its own settings page ("Test connection"). */ +export interface PluginAction { + key: string; label: string; hint?: string; danger: boolean +} + export const pluginsApi = { // Active plugins the client renders (page nav entries, dashboard widgets). active: () => apiClient.get('/plugins').then(r => r.data), @@ -675,7 +687,16 @@ export const pluginsApi = { // A user's OWN scope:'user' settings for a plugin (API key, prefs). Secrets are // masked; the write only accepts declared user-scope keys. userSettings: (id: string) => - apiClient.get(`/plugin-settings/${id}`).then(r => r.data as { fields: PluginUserSettingField[]; config: Record }), + apiClient.get(`/plugin-settings/${id}`).then(r => r.data as { + fields: PluginUserSettingField[] + config: Record + actions: PluginAction[] + }), + // Run a settings-page action the plugin declared ("Test connection"). It runs AS the + // caller, so it reads the caller's own settings. + runAction: (id: string, key: string) => + apiClient.post(`/plugin-settings/${id}/actions/${encodeURIComponent(key)}`) + .then(r => r.data as { ok: boolean; message?: string }), saveUserSettings: (id: string, config: Record) => apiClient.post(`/plugin-settings/${id}`, { config }).then(r => r.data as { config: Record }), // Host-brokered outbound OAuth (the host owns the tokens; the plugin only triggers). @@ -986,6 +1007,10 @@ export const notificationsApi = { testSmtp: (email?: string) => apiClient.post('/notifications/test-smtp', { email }).then(r => checkInDev(channelTestResultSchema, r.data, 'notifications.testSmtp')), testWebhook: (url?: string) => apiClient.post('/notifications/test-webhook', { url }).then(r => checkInDev(channelTestResultSchema, r.data, 'notifications.testWebhook')), testNtfy: (payload: { topic?: string; server?: string | null; token?: string | null }) => apiClient.post('/notifications/test-ntfy', payload).then(r => checkInDev(channelTestResultSchema, r.data, 'notifications.testNtfy')), + // Generic channel test — this is how a PLUGIN channel's "Send test" button works. + testChannel: (channelId: string) => + apiClient.post(`/notifications/test/${encodeURIComponent(channelId)}`) + .then(r => checkInDev(channelTestResultSchema, r.data, 'notifications.testChannel')), } export const inAppNotificationsApi = { diff --git a/client/src/components/Admin/AdminPluginsPanel.test.tsx b/client/src/components/Admin/AdminPluginsPanel.test.tsx new file mode 100644 index 000000000..3264de51c --- /dev/null +++ b/client/src/components/Admin/AdminPluginsPanel.test.tsx @@ -0,0 +1,121 @@ +import { http, HttpResponse } from 'msw' +import { beforeEach, describe, expect, it } from 'vitest' +import { server } from '../../../tests/helpers/msw/server' +import { fireEvent, render, screen, waitFor } from '../../../tests/helpers/render' +import { resetAllStores } from '../../../tests/helpers/store' +import AdminPluginsPanel from './AdminPluginsPanel' + +/** + * The "allowed hosts" chip. A plugin that talks to a SELF-HOSTED service (a Gotify) can't + * name the operator's host in its manifest, so the admin adds it — but they'd never know + * that unless the card says so. Until a host exists the plugin can reach NOTHING and looks + * silently broken, which is why the chip is warning-toned and actionable in that state. + */ +function plugin(over: Record = {}) { + return { + id: 'trek-gotify', name: 'Gotify', description: 'Push notifications', type: 'integration', + icon: 'Bell', version: '1.0.0', status: 'active', enabled: 1, + last_error: null, reviewed_at: null, source_repo: null, + permissions: JSON.stringify(['hook:notification-channel', 'http:outbound:gotify.net']), + capabilities: '{}', + operatorEgress: true, + egressHostCount: 0, + dependencyStatus: 'ok', + dependencyIssues: { disabledAddons: [], missing: [], versionMismatch: [] }, + ...over, + } +} + +function mockList(p: Record) { + server.use( + http.get('*/api/admin/plugins', () => HttpResponse.json({ enabled: true, devLink: false, plugins: [p] })), + http.get('*/api/admin/plugins/registry', () => HttpResponse.json({ plugins: [] })), + ) +} + +beforeEach(() => resetAllStores()) + +describe('AdminPluginsPanel — allowed-hosts chip', () => { + it('FE-COMP-PLUGINS-EGRESS-001: invites the admin to add a host when none is set', async () => { + mockList(plugin({ egressHostCount: 0 })) + render() + // The plugin can't reach anything yet — the card must say so, not stay silent. + expect(await screen.findByRole('button', { name: /add allowed host/i })).toBeInTheDocument() + }) + + it('FE-COMP-PLUGINS-EGRESS-002: shows the count once hosts exist', async () => { + mockList(plugin({ egressHostCount: 2 })) + render() + expect(await screen.findByRole('button', { name: /2 allowed host/i })).toBeInTheDocument() + expect(screen.queryByRole('button', { name: /add allowed host/i })).not.toBeInTheDocument() + }) + + it('FE-COMP-PLUGINS-EGRESS-003: a plugin that never declared operatorEgress gets NO chip', async () => { + mockList(plugin({ operatorEgress: false })) + render() + await screen.findByText('Gotify') + // An admin must never be invited to widen egress for a plugin that didn't ask for it. + expect(screen.queryByRole('button', { name: /allowed host/i })).not.toBeInTheDocument() + }) + + it('FE-COMP-PLUGINS-EGRESS-004: clicking the chip opens the allowed-hosts dialog', async () => { + mockList(plugin({ egressHostCount: 1 })) + server.use( + http.get('*/api/admin/plugins/trek-gotify/egress-hosts', () => + HttpResponse.json({ supported: true, hosts: ['gotify.mydomain.com'] })), + ) + render() + + fireEvent.click(await screen.findByRole('button', { name: /1 allowed host/i })) + await waitFor(() => expect(screen.getByText('gotify.mydomain.com')).toBeInTheDocument()) + }) +}) + +/** + * The Discover (pre-install) modal. Its "Connects to" list is what a reviewer reads to + * judge a plugin's network reach — so for an operatorEgress plugin that list is NOT the + * whole story, and saying nothing would actively mislead them. + */ +function mockDetail(manifest: Record | null) { + server.use( + http.get('*/api/admin/plugins', () => HttpResponse.json({ enabled: true, devLink: false, plugins: [] })), + // pluginBrowse returns the ARRAY itself, not { plugins: [...] }. + http.get('*/api/admin/plugins/registry', () => + HttpResponse.json([{ id: 'trek-gotify', name: 'Gotify', author: 'jubnl', description: 'Push', repo: 'jubnl/trek-gotify', type: 'integration', tags: [] }])), + http.get('*/api/admin/plugins/registry/trek-gotify', () => + HttpResponse.json({ + id: 'trek-gotify', name: 'Gotify', author: 'jubnl', description: 'Push', repo: 'jubnl/trek-gotify', + type: 'integration', tags: [], size: 1024, publishedAt: null, latest: '1.0.0', manifest, + })), + ) +} + +describe('AdminPluginsPanel — Discover modal, operator-egress pill', () => { + const base = { permissions: ['hook:notification-channel', 'http:outbound:gotify.net'], egress: ['gotify.net'], settings: [], license: 'MIT', icon: null } + + /** The panel opens on Installed — switch to Discover, then open the plugin's card. */ + async function openDetail() { + render() + fireEvent.click(await screen.findByRole('button', { name: /discover/i })) + fireEvent.click(await screen.findByText('Gotify')) + } + + it('FE-COMP-PLUGINS-EGRESS-005: warns that the host list is not the whole story', async () => { + mockDetail({ ...base, operatorEgress: true }) + await openDetail() + + // The declared host is still listed… + expect(await screen.findByText('gotify.net')).toBeInTheDocument() + // …alongside the pill saying an admin adds more. + expect(screen.getByText(/hosts you add/i)).toBeInTheDocument() + }) + + it('FE-COMP-PLUGINS-EGRESS-006: an ordinary plugin gets NO such pill', async () => { + mockDetail({ ...base, operatorEgress: false }) + await openDetail() + + expect(await screen.findByText('gotify.net')).toBeInTheDocument() + // Its egress list IS the whole story — claiming otherwise would be a lie. + expect(screen.queryByText(/hosts you add/i)).not.toBeInTheDocument() + }) +}) diff --git a/client/src/components/Admin/AdminPluginsPanel.tsx b/client/src/components/Admin/AdminPluginsPanel.tsx index cad551594..00d8b4af9 100644 --- a/client/src/components/Admin/AdminPluginsPanel.tsx +++ b/client/src/components/Admin/AdminPluginsPanel.tsx @@ -42,6 +42,10 @@ interface PluginRow { source_repo: string | null permissions: string capabilities: string + /** The plugin needs OPERATOR-supplied egress hosts (a self-hosted target). */ + operatorEgress?: boolean + /** How many hosts the admin has added — 0 means the plugin can't reach anything yet. */ + egressHostCount?: number dependencies?: PluginDependencies dependencyStatus?: DependencyStatus dependencyIssues?: DependencyIssues @@ -68,6 +72,8 @@ interface RegistryDetail extends RegistryItem { manifest: { permissions: string[] egress: string[] + /** The plugin needs OPERATOR-supplied hosts — its egress list is not the whole story. */ + operatorEgress?: boolean settings: Array<{ key: string; label: string; inputType: string; scope: string; required: boolean }> license: string | null icon: string | null @@ -118,7 +124,7 @@ const PERM_KEYS = [ 'events:subscribe', 'jobs:run', 'ws:broadcast:trip', 'ws:broadcast:user', 'hook:photo-provider', 'hook:calendar-source', 'hook:place-detail-provider', 'hook:trip-warning-provider', 'hook:table-contributor', 'hook:map-marker-provider', - 'hook:pdf-section-provider', 'hook:atlas-layer-provider', 'hook:journal-entry-provider', 'hook:trip-card-provider', 'hook:user-data', 'http:outbound', + 'hook:pdf-section-provider', 'hook:atlas-layer-provider', 'hook:journal-entry-provider', 'hook:trip-card-provider', 'hook:notification-channel', 'hook:user-data', 'http:outbound', ] const KNOWN_TYPES = ['widget', 'page', 'integration', 'trip-page'] @@ -169,6 +175,7 @@ function deriveCaps(perms: string[], caps: { widget?: { slot?: string }; tripPag if (perms.includes('hook:calendar-source')) out.push({ icon: CalendarDays, label: t('admin.plugins.cap.calendar') }) if (perms.includes('hook:place-detail-provider')) out.push({ icon: MapPin, label: t('admin.plugins.cap.placeDetails') }) if (perms.includes('hook:trip-warning-provider')) out.push({ icon: AlertTriangle, label: t('admin.plugins.cap.warnings') }) + if (perms.includes('hook:notification-channel')) out.push({ icon: Bell, label: t('admin.plugins.cap.notificationChannel') }) if (perms.includes('events:subscribe')) out.push({ icon: Radio, label: t('admin.plugins.cap.events') }) for (const h of perms.filter(p => p.startsWith('http:outbound:')).map(p => p.slice('http:outbound:'.length)).filter(Boolean)) { out.push({ icon: ArrowRight, label: h, net: true }) @@ -250,6 +257,10 @@ export default function AdminPluginsPanel() { const [latest, setLatest] = useState>({}) const [detailFor, setDetailFor] = useState(null) const [errorsFor, setErrorsFor] = useState<{ id: string; rows: Array<{ ts: string; level: string; message: string }> } | null>(null) + const [egressFor, setEgressFor] = useState<{ id: string; supported: boolean; hosts: string[] } | null>(null) + const [egressDraft, setEgressDraft] = useState('') + const [egressSaving, setEgressSaving] = useState(false) + const [egressError, setEgressError] = useState('') const [confirmUninstall, setConfirmUninstall] = useState(null) // A QUEUE, not one slot: "Update All" can produce several re-consent prompts — // each must be shown, not silently overwritten by the last one. @@ -349,6 +360,31 @@ export default function AdminPluginsPanel() { const f = e.dataTransfer.files?.[0] if (f) void uploadPlugin(f) } + const openEgress = (id: string) => { + setMenu(null) + setEgressDraft(''); setEgressError('') + adminApi.pluginEgressHosts(id) + .then(d => setEgressFor({ id, supported: d.supported, hosts: d.hosts })) + .catch(() => setEgressFor({ id, supported: false, hosts: [] })) + } + + // Saving RE-SPAWNS the plugin: the child's egress guard is installed once at init and + // a second init is refused, so a live child's allow-list can never be widened in place. + const saveEgress = async (hosts: string[]) => { + if (!egressFor) return + setEgressSaving(true); setEgressError('') + try { + const d = await adminApi.pluginSetEgressHosts(egressFor.id, hosts) + setEgressFor({ ...egressFor, hosts: d.hosts }) + setEgressDraft('') + } catch (e) { + const err = e as { response?: { data?: { error?: string } } } + setEgressError(err.response?.data?.error || t('common.error')) + } finally { + setEgressSaving(false) + } + } + const openErrors = (id: string) => { setMenu(null) adminApi.pluginErrors(id) @@ -657,7 +693,8 @@ export default function AdminPluginsPanel() { hasUpdate={updateAvailable(p)} latestVer={latest[p.id]} onToggle={() => toggle(p)} onUpdate={() => runUpdate(p)} onRestart={() => restart(p.id)} - onErrors={() => openErrors(p.id)} onUninstall={() => { setMenu(null); setConfirmUninstall(p) }} /> + onErrors={() => openErrors(p.id)} onEgress={() => openEgress(p.id)} + onUninstall={() => { setMenu(null); setConfirmUninstall(p) }} /> ))} )} @@ -693,6 +730,56 @@ export default function AdminPluginsPanel() { )} + {/* Operator-supplied egress hosts */} + {egressFor && ( +
setEgressFor(null)}> +
e.stopPropagation()}> +
+ {egressFor.id} — {t('admin.plugins.allowedHosts')} + +
+
+ {!egressFor.supported ? ( +

{t('admin.plugins.allowedHosts.unsupported')}

+ ) : ( + <> +

{t('admin.plugins.allowedHosts.hint')}

+ {egressFor.hosts.length === 0 && ( +

{t('admin.plugins.allowedHosts.none')}

+ )} + {egressFor.hosts.map(h => ( +
+ {h} + +
+ ))} +
+ setEgressDraft(e.target.value)} + placeholder="gotify.example.com" + className="flex-1 rounded-lg border border-edge bg-surface px-3 py-2 text-sm text-content" + /> + +
+ {egressError &&

{egressError}

} +

{t('admin.plugins.allowedHosts.restartNote')}

+ + )} +
+
+
+ )} + setConfirmUninstall(null)} @@ -799,10 +886,10 @@ function EmptyState({ t, onDiscover }: { t: T; onDiscover: () => void }) { ) } -function InstalledRow({ p, t, busy, menu, setMenu, hasUpdate, latestVer, onToggle, onUpdate, onRestart, onErrors, onUninstall }: { +function InstalledRow({ p, t, busy, menu, setMenu, hasUpdate, latestVer, onToggle, onUpdate, onRestart, onErrors, onEgress, onUninstall }: { p: PluginRow; t: T; busy: string | null; menu: string | null; setMenu: (v: string | null) => void hasUpdate: boolean; latestVer?: string - onToggle: () => void; onUpdate: () => void; onRestart: () => void; onErrors: () => void; onUninstall: () => void + onToggle: () => void; onUpdate: () => void; onRestart: () => void; onErrors: () => void; onEgress: () => void; onUninstall: () => void }) { const caps = deriveCaps(parseJson(p.permissions, []), parseJson<{ widget?: { slot?: string } }>(p.capabilities, {}), t) const deps = deriveDeps(p, t) @@ -830,7 +917,7 @@ function InstalledRow({ p, t, busy, menu, setMenu, hasUpdate, latestVer, onToggl
{p.last_error}
- ) : caps.length > 0 && ( + ) : (caps.length > 0 || p.operatorEgress) && (
{caps.map((c, i) => ( {c.label} ))} + {/* This plugin talks to a service only the OPERATOR can name (a self-hosted + Gotify/ntfy), so its manifest can't list the host — the admin adds it. + Actionable, and warning-toned until at least one host exists, because + until then the plugin cannot reach anything and looks silently broken. */} + {p.operatorEgress && ( + + )}
)} {deps.length > 0 && ( @@ -874,6 +980,7 @@ function InstalledRow({ p, t, busy, menu, setMenu, hasUpdate, latestVer, onToggl } label={t('admin.plugins.restart')} onClick={onRestart} /> )} } label={t('admin.plugins.viewErrors')} onClick={onErrors} /> + } label={t('admin.plugins.allowedHosts')} onClick={onEgress} /> {p.source_repo && p.source_repo !== 'local:upload' && p.source_repo !== 'local:link' && ( <> setMenu(null)} @@ -1070,14 +1177,26 @@ function PluginDetailModal({ item, installed, busy, onInstall, onClose, t, local )} - {manifest && manifest.egress.length > 0 && ( + {manifest && (manifest.egress.length > 0 || manifest.operatorEgress) && (

{t('admin.plugins.connectsTitle')}

-
+
{manifest.egress.map(h => ( {h} ))} + {/* The hosts above are NOT the whole story for this plugin: it talks to a + service only the operator can name, so its reach is whatever an admin + adds after install. Say so HERE — this is the pre-install review, and a + reviewer who reads only the host list would otherwise be misled. */} + {manifest.operatorEgress && ( + + {t('admin.plugins.operatorEgressPill')} + + )}
+ {manifest.operatorEgress && ( +

{t('admin.plugins.operatorEgressHint')}

+ )}
)} diff --git a/client/src/components/Settings/NotificationsTab.test.tsx b/client/src/components/Settings/NotificationsTab.test.tsx index d712d5ffd..d52b8500c 100644 --- a/client/src/components/Settings/NotificationsTab.test.tsx +++ b/client/src/components/Settings/NotificationsTab.test.tsx @@ -1,11 +1,10 @@ -import React from 'react'; -import { render, screen, waitFor } from '../../../tests/helpers/render'; import userEvent from '@testing-library/user-event'; import { http, HttpResponse } from 'msw'; -import { server } from '../../../tests/helpers/msw/server'; -import { useAuthStore } from '../../store/authStore'; -import { resetAllStores, seedStore } from '../../../tests/helpers/store'; import { buildUser } from '../../../tests/helpers/factories'; +import { server } from '../../../tests/helpers/msw/server'; +import { render, screen, waitFor } from '../../../tests/helpers/render'; +import { resetAllStores, seedStore } from '../../../tests/helpers/store'; +import { useAuthStore } from '../../store/authStore'; import { ToastContainer } from '../shared/Toast'; import NotificationsTab from './NotificationsTab'; @@ -13,7 +12,29 @@ const minimalMatrix = { preferences: { trip_invite: { inapp: true, email: false }, }, - available_channels: { email: true, webhook: false, inapp: true }, + channels: [ + { + id: 'email', + source: 'builtin', + labelKey: 'settings.notificationPreferences.email', + active: true, + configured: true, + }, + { + id: 'webhook', + source: 'builtin', + labelKey: 'settings.notificationPreferences.webhook', + active: false, + configured: true, + }, + { + id: 'inapp', + source: 'builtin', + labelKey: 'settings.notificationPreferences.inapp', + active: true, + configured: true, + }, + ], event_types: ['trip_invite'], implemented_combos: { trip_invite: ['inapp', 'email'] }, }; @@ -25,15 +46,13 @@ beforeEach(() => { server.use( http.get('/api/notifications/preferences', () => HttpResponse.json(minimalMatrix)), http.get('/api/settings', () => HttpResponse.json({ settings: { webhook_url: '' } })), - http.put('/api/notifications/preferences', () => HttpResponse.json({ success: true })), + http.put('/api/notifications/preferences', () => HttpResponse.json({ success: true })) ); }); describe('NotificationsTab', () => { it('FE-COMP-NOTIFICATIONS-001: shows loading state initially', () => { - server.use( - http.get('/api/notifications/preferences', () => new Promise(() => {})), - ); + server.use(http.get('/api/notifications/preferences', () => new Promise(() => {}))); render(); expect(screen.getByText('Loading...')).toBeInTheDocument(); }); @@ -64,11 +83,33 @@ describe('NotificationsTab', () => { http.get('/api/notifications/preferences', () => HttpResponse.json({ preferences: {}, - available_channels: { email: false, webhook: false, inapp: false }, + channels: [ + { + id: 'email', + source: 'builtin', + labelKey: 'settings.notificationPreferences.email', + active: false, + configured: true, + }, + { + id: 'webhook', + source: 'builtin', + labelKey: 'settings.notificationPreferences.webhook', + active: false, + configured: true, + }, + { + id: 'inapp', + source: 'builtin', + labelKey: 'settings.notificationPreferences.inapp', + active: false, + configured: true, + }, + ], event_types: ['trip_invite'], implemented_combos: { trip_invite: ['inapp', 'email'] }, - }), - ), + }) + ) ); render(); await waitFor(() => { @@ -86,14 +127,36 @@ describe('NotificationsTab', () => { http.get('/api/notifications/preferences', () => HttpResponse.json({ preferences: { trip_invite: { inapp: true }, booking_change: { email: true } }, - available_channels: { email: true, webhook: false, inapp: true }, + channels: [ + { + id: 'email', + source: 'builtin', + labelKey: 'settings.notificationPreferences.email', + active: true, + configured: true, + }, + { + id: 'webhook', + source: 'builtin', + labelKey: 'settings.notificationPreferences.webhook', + active: false, + configured: true, + }, + { + id: 'inapp', + source: 'builtin', + labelKey: 'settings.notificationPreferences.inapp', + active: true, + configured: true, + }, + ], event_types: ['trip_invite', 'booking_change'], implemented_combos: { - trip_invite: ['inapp'], // no email → dash in email column - booking_change: ['email'], // no inapp → dash in inapp column + trip_invite: ['inapp'], // no email → dash in email column + booking_change: ['email'], // no inapp → dash in inapp column }, - }), - ), + }) + ) ); render(); await waitFor(() => { @@ -111,7 +174,7 @@ describe('NotificationsTab', () => { http.put('/api/notifications/preferences', async ({ request }) => { capturedBody = await request.json(); return HttpResponse.json({ success: true }); - }), + }) ); render(); @@ -138,9 +201,7 @@ describe('NotificationsTab', () => { it('FE-COMP-NOTIFICATIONS-007: toggle rolls back on API error', async () => { const user = userEvent.setup(); - server.use( - http.put('/api/notifications/preferences', () => HttpResponse.json({ error: 'fail' }, { status: 500 })), - ); + server.use(http.put('/api/notifications/preferences', () => HttpResponse.json({ error: 'fail' }, { status: 500 }))); render(); await waitFor(() => { @@ -169,11 +230,13 @@ describe('NotificationsTab', () => { const user = userEvent.setup(); let resolveRequest!: () => void; server.use( - http.put('/api/notifications/preferences', () => - new Promise(resolve => { - resolveRequest = () => resolve(HttpResponse.json({ success: true }) as unknown as Response); - }), - ), + http.put( + '/api/notifications/preferences', + () => + new Promise((resolve) => { + resolveRequest = () => resolve(HttpResponse.json({ success: true }) as unknown as Response); + }) + ) ); render(); @@ -200,11 +263,33 @@ describe('NotificationsTab', () => { http.get('/api/notifications/preferences', () => HttpResponse.json({ preferences: { trip_invite: { inapp: true, webhook: false } }, - available_channels: { email: false, webhook: true, inapp: true }, + channels: [ + { + id: 'email', + source: 'builtin', + labelKey: 'settings.notificationPreferences.email', + active: false, + configured: true, + }, + { + id: 'webhook', + source: 'builtin', + labelKey: 'settings.notificationPreferences.webhook', + active: true, + configured: true, + }, + { + id: 'inapp', + source: 'builtin', + labelKey: 'settings.notificationPreferences.inapp', + active: true, + configured: true, + }, + ], event_types: ['trip_invite'], implemented_combos: { trip_invite: ['inapp', 'webhook'] }, - }), - ), + }) + ) ); render(); @@ -218,7 +303,7 @@ describe('NotificationsTab', () => { // Save button should be present const buttons = screen.getAllByRole('button'); - expect(buttons.some(b => /save/i.test(b.textContent || ''))).toBe(true); + expect(buttons.some((b) => /save/i.test(b.textContent || ''))).toBe(true); }); it('FE-COMP-NOTIFICATIONS-010: webhook URL input shows masked placeholder when webhook is already set', async () => { @@ -226,14 +311,34 @@ describe('NotificationsTab', () => { http.get('/api/notifications/preferences', () => HttpResponse.json({ preferences: { trip_invite: { inapp: true, webhook: false } }, - available_channels: { email: false, webhook: true, inapp: true }, + channels: [ + { + id: 'email', + source: 'builtin', + labelKey: 'settings.notificationPreferences.email', + active: false, + configured: true, + }, + { + id: 'webhook', + source: 'builtin', + labelKey: 'settings.notificationPreferences.webhook', + active: true, + configured: true, + }, + { + id: 'inapp', + source: 'builtin', + labelKey: 'settings.notificationPreferences.inapp', + active: true, + configured: true, + }, + ], event_types: ['trip_invite'], implemented_combos: { trip_invite: ['inapp', 'webhook'] }, - }), - ), - http.get('/api/settings', () => - HttpResponse.json({ settings: { webhook_url: '••••••••' } }), + }) ), + http.get('/api/settings', () => HttpResponse.json({ settings: { webhook_url: '••••••••' } })) ); render(); @@ -252,15 +357,37 @@ describe('NotificationsTab', () => { http.get('/api/notifications/preferences', () => HttpResponse.json({ preferences: { trip_invite: { inapp: true, webhook: false } }, - available_channels: { email: false, webhook: true, inapp: true }, + channels: [ + { + id: 'email', + source: 'builtin', + labelKey: 'settings.notificationPreferences.email', + active: false, + configured: true, + }, + { + id: 'webhook', + source: 'builtin', + labelKey: 'settings.notificationPreferences.webhook', + active: true, + configured: true, + }, + { + id: 'inapp', + source: 'builtin', + labelKey: 'settings.notificationPreferences.inapp', + active: true, + configured: true, + }, + ], event_types: ['trip_invite'], implemented_combos: { trip_invite: ['inapp', 'webhook'] }, - }), + }) ), http.put('/api/settings', async ({ request }) => { capturedBody = await request.json(); return HttpResponse.json({ success: true }); - }), + }) ); render(); @@ -271,7 +398,7 @@ describe('NotificationsTab', () => { const input = await screen.findByRole('textbox'); await user.type(input, 'https://example.com/hook'); - const saveBtn = screen.getAllByRole('button').find(b => /save/i.test(b.textContent || '')); + const saveBtn = screen.getAllByRole('button').find((b) => /save/i.test(b.textContent || '')); expect(saveBtn).toBeDefined(); await user.click(saveBtn!); @@ -285,14 +412,34 @@ describe('NotificationsTab', () => { http.get('/api/notifications/preferences', () => HttpResponse.json({ preferences: { trip_invite: { inapp: true, webhook: false } }, - available_channels: { email: false, webhook: true, inapp: true }, + channels: [ + { + id: 'email', + source: 'builtin', + labelKey: 'settings.notificationPreferences.email', + active: false, + configured: true, + }, + { + id: 'webhook', + source: 'builtin', + labelKey: 'settings.notificationPreferences.webhook', + active: true, + configured: true, + }, + { + id: 'inapp', + source: 'builtin', + labelKey: 'settings.notificationPreferences.inapp', + active: true, + configured: true, + }, + ], event_types: ['trip_invite'], implemented_combos: { trip_invite: ['inapp', 'webhook'] }, - }), - ), - http.get('/api/settings', () => - HttpResponse.json({ settings: { webhook_url: '' } }), + }) ), + http.get('/api/settings', () => HttpResponse.json({ settings: { webhook_url: '' } })) ); render(); @@ -301,7 +448,7 @@ describe('NotificationsTab', () => { }); await screen.findByRole('textbox'); - const testBtn = screen.getAllByRole('button').find(b => /test/i.test(b.textContent || '')); + const testBtn = screen.getAllByRole('button').find((b) => /test/i.test(b.textContent || '')); expect(testBtn).toBeDefined(); expect(testBtn).toBeDisabled(); }); @@ -312,21 +459,41 @@ describe('NotificationsTab', () => { http.get('/api/notifications/preferences', () => HttpResponse.json({ preferences: { trip_invite: { inapp: true, webhook: false } }, - available_channels: { email: false, webhook: true, inapp: true }, + channels: [ + { + id: 'email', + source: 'builtin', + labelKey: 'settings.notificationPreferences.email', + active: false, + configured: true, + }, + { + id: 'webhook', + source: 'builtin', + labelKey: 'settings.notificationPreferences.webhook', + active: true, + configured: true, + }, + { + id: 'inapp', + source: 'builtin', + labelKey: 'settings.notificationPreferences.inapp', + active: true, + configured: true, + }, + ], event_types: ['trip_invite'], implemented_combos: { trip_invite: ['inapp', 'webhook'] }, - }), - ), - http.post('/api/notifications/test-webhook', () => - HttpResponse.json({ success: true }), + }) ), + http.post('/api/notifications/test-webhook', () => HttpResponse.json({ success: true })) ); render( <> - , + ); await waitFor(() => { @@ -336,7 +503,7 @@ describe('NotificationsTab', () => { const input = await screen.findByRole('textbox'); await user.type(input, 'https://example.com/hook'); - const testBtn = screen.getAllByRole('button').find(b => /test/i.test(b.textContent || '')); + const testBtn = screen.getAllByRole('button').find((b) => /test/i.test(b.textContent || '')); expect(testBtn).toBeDefined(); await user.click(testBtn!); @@ -352,11 +519,40 @@ describe('NotificationsTab', () => { http.get('/api/notifications/preferences', () => HttpResponse.json({ preferences: { trip_invite: { inapp: true, ntfy: false } }, - available_channels: { email: false, webhook: false, inapp: true, ntfy: true }, + channels: [ + { + id: 'email', + source: 'builtin', + labelKey: 'settings.notificationPreferences.email', + active: false, + configured: true, + }, + { + id: 'webhook', + source: 'builtin', + labelKey: 'settings.notificationPreferences.webhook', + active: false, + configured: true, + }, + { + id: 'inapp', + source: 'builtin', + labelKey: 'settings.notificationPreferences.inapp', + active: true, + configured: true, + }, + { + id: 'ntfy', + source: 'builtin', + labelKey: 'settings.notificationPreferences.ntfy', + active: true, + configured: true, + }, + ], event_types: ['trip_invite'], implemented_combos: { trip_invite: ['inapp', 'ntfy'] }, - }), - ), + }) + ) ); render(); @@ -374,12 +570,41 @@ describe('NotificationsTab', () => { http.get('/api/notifications/preferences', () => HttpResponse.json({ preferences: { trip_invite: { inapp: true, ntfy: false } }, - available_channels: { email: false, webhook: false, inapp: true, ntfy: true }, + channels: [ + { + id: 'email', + source: 'builtin', + labelKey: 'settings.notificationPreferences.email', + active: false, + configured: true, + }, + { + id: 'webhook', + source: 'builtin', + labelKey: 'settings.notificationPreferences.webhook', + active: false, + configured: true, + }, + { + id: 'inapp', + source: 'builtin', + labelKey: 'settings.notificationPreferences.inapp', + active: true, + configured: true, + }, + { + id: 'ntfy', + source: 'builtin', + labelKey: 'settings.notificationPreferences.ntfy', + active: true, + configured: true, + }, + ], event_types: ['trip_invite'], implemented_combos: { trip_invite: ['inapp', 'ntfy'] }, - }), + }) ), - http.get('/api/settings', () => HttpResponse.json({ settings: { ntfy_topic: '' } })), + http.get('/api/settings', () => HttpResponse.json({ settings: { ntfy_topic: '' } })) ); render(); @@ -389,7 +614,7 @@ describe('NotificationsTab', () => { // 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 || '')); + const testBtn = allButtons.find((b) => /test/i.test(b.textContent || '')); expect(testBtn).toBeDefined(); expect(testBtn).toBeDisabled(); }); @@ -401,22 +626,51 @@ describe('NotificationsTab', () => { http.get('/api/notifications/preferences', () => HttpResponse.json({ preferences: { trip_invite: { inapp: true, ntfy: false } }, - available_channels: { email: false, webhook: false, inapp: true, ntfy: true }, + channels: [ + { + id: 'email', + source: 'builtin', + labelKey: 'settings.notificationPreferences.email', + active: false, + configured: true, + }, + { + id: 'webhook', + source: 'builtin', + labelKey: 'settings.notificationPreferences.webhook', + active: false, + configured: true, + }, + { + id: 'inapp', + source: 'builtin', + labelKey: 'settings.notificationPreferences.inapp', + active: true, + configured: true, + }, + { + id: 'ntfy', + source: 'builtin', + labelKey: 'settings.notificationPreferences.ntfy', + active: true, + configured: 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( <> - , + ); await waitFor(() => { @@ -429,7 +683,7 @@ describe('NotificationsTab', () => { // Test button should now be enabled const allButtons = screen.getAllByRole('button'); - const testBtn = allButtons.find(b => /test/i.test(b.textContent || '')); + const testBtn = allButtons.find((b) => /test/i.test(b.textContent || '')); expect(testBtn).toBeDefined(); expect(testBtn).not.toBeDisabled(); @@ -446,21 +700,43 @@ describe('NotificationsTab', () => { http.get('/api/notifications/preferences', () => HttpResponse.json({ preferences: { trip_invite: { inapp: true, webhook: false } }, - available_channels: { email: false, webhook: true, inapp: true }, + channels: [ + { + id: 'email', + source: 'builtin', + labelKey: 'settings.notificationPreferences.email', + active: false, + configured: true, + }, + { + id: 'webhook', + source: 'builtin', + labelKey: 'settings.notificationPreferences.webhook', + active: true, + configured: true, + }, + { + id: 'inapp', + source: 'builtin', + labelKey: 'settings.notificationPreferences.inapp', + active: true, + configured: true, + }, + ], event_types: ['trip_invite'], implemented_combos: { trip_invite: ['inapp', 'webhook'] }, - }), + }) ), http.post('/api/notifications/test-webhook', () => - HttpResponse.json({ success: false, error: 'Connection refused' }), - ), + HttpResponse.json({ success: false, error: 'Connection refused' }) + ) ); render( <> - , + ); await waitFor(() => { @@ -470,7 +746,7 @@ describe('NotificationsTab', () => { const input = await screen.findByRole('textbox'); await user.type(input, 'https://example.com/hook'); - const testBtn = screen.getAllByRole('button').find(b => /test/i.test(b.textContent || '')); + const testBtn = screen.getAllByRole('button').find((b) => /test/i.test(b.textContent || '')); expect(testBtn).toBeDefined(); await user.click(testBtn!); @@ -480,3 +756,98 @@ describe('NotificationsTab', () => { }); }); }); + +// ───────────────────────────────────────────────────────────────────────────── +// Plugin notification channels (e.g. a Gotify plugin) +// ───────────────────────────────────────────────────────────────────────────── +// +// A plugin channel must be a first-class citizen here alongside in-app/email/ntfy: +// its own column in the matrix, its state visible, and a way to act on it. The +// credentials themselves live on the plugin's settings page, so an UNconfigured +// channel links there rather than just naming the place. + +const pluginMatrix = (over: Record = {}) => ({ + preferences: { trip_invite: { inapp: true, 'plugin:trek-gotify': true } }, + channels: [ + { id: 'inapp', source: 'builtin', labelKey: 'settings.notificationPreferences.inapp', active: true, configured: true }, + { + id: 'plugin:trek-gotify', + source: 'plugin', + label: 'Gotify', + settingsPath: '/settings?tab=plugins', + active: true, + configured: true, + ...over, + }, + ], + event_types: ['trip_invite'], + implemented_combos: { trip_invite: ['inapp', 'plugin:trek-gotify'] }, +}); + +function mockMatrix(matrix: unknown) { + server.use( + http.get('*/api/notifications/preferences', () => HttpResponse.json(matrix)), + http.get('*/api/settings', () => HttpResponse.json({ settings: {} })), + ); +} + +describe('NotificationsTab — plugin channels', () => { + beforeEach(() => { + resetAllStores(); + seedStore(useAuthStore, { isAuthenticated: true, user: buildUser() }); + }); + + it('FE-COMP-NOTIFICATIONS-PLUGIN-001: a configured plugin channel gets its own column, by its own name', async () => { + mockMatrix(pluginMatrix()); + render(); + // The server sends the display name outright — plugin channels have no i18n key. + // It appears TWICE by design: once as the channel's own card, once as the matrix + // column header — i.e. it really is a first-class column, not just a side panel. + const shown = await screen.findAllByText('Gotify'); + expect(shown.length).toBe(2); + // …and it is togglable per event, exactly like a built-in. + await waitFor(() => expect(screen.getAllByRole('button').length).toBeGreaterThan(0)); + }); + + it('FE-COMP-NOTIFICATIONS-PLUGIN-002: configured → offers a test send, no Configure link', async () => { + mockMatrix(pluginMatrix({ configured: true })); + render(); + + const test = await screen.findByRole('button', { name: /send test/i }); + expect(test).toBeEnabled(); + expect(screen.queryByRole('link', { name: /configure/i })).not.toBeInTheDocument(); + }); + + it('FE-COMP-NOTIFICATIONS-PLUGIN-003: NOT configured → links to the plugin settings tab, test disabled', async () => { + mockMatrix(pluginMatrix({ configured: false })); + render(); + + const link = await screen.findByRole('link', { name: /configure/i }); + // A real route — settings is one page with a ?tab= param, not a route per plugin. + expect(link).toHaveAttribute('href', '/settings?tab=plugins'); + expect(screen.getByRole('button', { name: /send test/i })).toBeDisabled(); + }); + + it('FE-COMP-NOTIFICATIONS-PLUGIN-004: Send test calls the generic channel-test route', async () => { + mockMatrix(pluginMatrix()); + let called = ''; + server.use( + http.post('*/api/notifications/test/:channelId', ({ params }) => { + called = String(params.channelId); + return HttpResponse.json({ success: true }); + }), + ); + render(<>); + + await userEvent.click(await screen.findByRole('button', { name: /send test/i })); + await waitFor(() => expect(called).toBe('plugin:trek-gotify')); + }); + + it('FE-COMP-NOTIFICATIONS-PLUGIN-005: an INACTIVE plugin channel is not shown at all', async () => { + mockMatrix(pluginMatrix({ active: false })); + render(); + await screen.findByText(/in-app/i); + // The admin hasn't enabled the channel — it must not appear as an option. + expect(screen.queryByText('Gotify')).not.toBeInTheDocument(); + }); +}); diff --git a/client/src/components/Settings/NotificationsTab.tsx b/client/src/components/Settings/NotificationsTab.tsx index a66085b41..dcaf1873d 100644 --- a/client/src/components/Settings/NotificationsTab.tsx +++ b/client/src/components/Settings/NotificationsTab.tsx @@ -1,4 +1,5 @@ import React, { useState, useEffect } from 'react' +import { Link } from 'react-router-dom' import { Lock } from 'lucide-react' import { useTranslation } from '../../i18n' import { notificationsApi, settingsApi } from '../../api/client' @@ -6,19 +7,30 @@ import { useToast } from '../shared/Toast' import ToggleSwitch from './ToggleSwitch' import Section from './Section' +interface ChannelDescriptor { + id: string + source: 'builtin' | 'plugin' + /** Built-ins: an i18n key. */ + labelKey?: string + /** Plugin channels: a literal name, already resolved by the server. */ + label?: string + settingsPath?: string + active: boolean + configured: boolean +} + interface PreferencesMatrix { preferences: Record> - available_channels: { email: boolean; webhook: boolean; inapp: boolean; ntfy: boolean } + channels: ChannelDescriptor[] event_types: string[] implemented_combos: Record defaults?: { ntfyServer: string | null } } -const CHANNEL_LABEL_KEYS: Record = { - email: 'settings.notificationPreferences.email', - webhook: 'settings.notificationPreferences.webhook', - inapp: 'settings.notificationPreferences.inapp', - ntfy: 'settings.notificationPreferences.ntfy', +/** 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 = { @@ -48,6 +60,7 @@ export default function NotificationsTab(): React.ReactElement { const [ntfyTokenIsSet, setNtfyTokenIsSet] = useState(false) const [ntfySaving, setNtfySaving] = useState(false) const [ntfyTesting, setNtfyTesting] = useState(false) + const [channelTesting, setChannelTesting] = useState(null) useEffect(() => { notificationsApi.getPreferences().then((data: PreferencesMatrix) => setMatrix(data)).catch(() => {}) @@ -71,13 +84,34 @@ export default function NotificationsTab(): React.ReactElement { }).catch(() => {}) }, []) + // Columns are whatever the server says exists and the admin turned on — so a + // plugin channel gets a column here with no client change. const visibleChannels = matrix - ? (['email', 'webhook', 'ntfy', 'inapp'] as const).filter(ch => { - if (!matrix.available_channels[ch as keyof typeof matrix.available_channels]) return false - return matrix.event_types.some(evt => matrix.implemented_combos[evt]?.includes(ch)) + ? matrix.channels.filter(ch => { + if (!ch.active) return false + return matrix.event_types.some(evt => matrix.implemented_combos[evt]?.includes(ch.id)) }) : [] + const hasChannel = (id: string) => matrix?.channels.some(ch => ch.id === id && ch.active) ?? false + + // Plugin channels have no bespoke credential form here — the user fills those in on the + // plugin's own settings page. What they DO get is a test send, through the generic route. + const pluginChannels = matrix?.channels.filter(ch => ch.source === 'plugin' && ch.active) ?? [] + + const testChannel = async (ch: ChannelDescriptor) => { + setChannelTesting(ch.id) + try { + const result = await notificationsApi.testChannel(ch.id) + if (result.success) toast.success(t('settings.notificationPreferences.testSuccess')) + else toast.error(result.error || t('settings.notificationPreferences.testFailed')) + } catch { + toast.error(t('settings.notificationPreferences.testFailed')) + } finally { + setChannelTesting(null) + } + } + const toggle = async (eventType: string, channel: string) => { if (!matrix) return const current = matrix.preferences[eventType]?.[channel] ?? true @@ -184,7 +218,7 @@ export default function NotificationsTab(): React.ReactElement { return (
{saving &&

{t('common.saving')}

} - {matrix.available_channels.webhook && ( + {hasChannel('webhook') && (
)} - {matrix.available_channels.ntfy && ( + {hasChannel('ntfy') && (
)} + {pluginChannels.map(ch => ( +
+ +

+ {ch.configured + ? t('settings.notificationPreferences.pluginConfigured') + : t('settings.notificationPreferences.notConfigured')} +

+
+ {/* Unconfigured is the common case on first use — send them to where the + credentials actually live rather than just naming the place. */} + {!ch.configured && ch.settingsPath && ( + + {t('settings.notificationPreferences.configure')} + + )} + +
+
+ ))} {/* Header row */}
'64px').join(' ')}`, gap: 4, paddingBottom: 6, marginBottom: 4, borderBottom: '1px solid var(--border-primary)' }}> {visibleChannels.map(ch => ( - - {t(CHANNEL_LABEL_KEYS[ch]) || ch} + + {channelLabel(ch, t)} ))}
{/* Event rows */} {matrix.event_types.map(eventType => { const implementedForEvent = matrix.implemented_combos[eventType] ?? [] - const relevantChannels = visibleChannels.filter(ch => implementedForEvent.includes(ch)) + const relevantChannels = visibleChannels.filter(ch => implementedForEvent.includes(ch.id)) if (relevantChannels.length === 0) return null return (
'64px').join(' ')}`, gap: 4, alignItems: 'center', padding: '6px 0', borderBottom: '1px solid var(--border-primary)' }}> @@ -295,13 +360,13 @@ export default function NotificationsTab(): React.ReactElement { {t(EVENT_LABEL_KEYS[eventType]) || eventType} {visibleChannels.map(ch => { - if (!implementedForEvent.includes(ch)) { - return — + if (!implementedForEvent.includes(ch.id)) { + return — } - const isOn = matrix.preferences[eventType]?.[ch] ?? true + const isOn = matrix.preferences[eventType]?.[ch.id] ?? true return ( -
- toggle(eventType, ch)} /> +
+ toggle(eventType, ch.id)} />
) })} diff --git a/client/src/components/Settings/PluginSettingsTab.tsx b/client/src/components/Settings/PluginSettingsTab.tsx index 57affcea3..c561b9629 100644 --- a/client/src/components/Settings/PluginSettingsTab.tsx +++ b/client/src/components/Settings/PluginSettingsTab.tsx @@ -1,6 +1,6 @@ import { useEffect, useState } from 'react' import { Blocks, Save, Loader2, Link2, Unlink, CheckCircle } from 'lucide-react' -import { pluginsApi, type PluginUserSettingField } from '../../api/client' +import { pluginsApi, type PluginUserSettingField, type PluginAction } from '../../api/client' import { usePluginStore } from '../../store/pluginStore' import { useToast } from '../shared/Toast' import { useTranslation } from '../../i18n' @@ -64,6 +64,9 @@ function PluginSettingsForm({ id, name }: { id: string; name: string }) { const [values, setValues] = useState>({}) const [saving, setSaving] = useState(false) const [oauth, setOauth] = useState<{ configured: boolean; connected: boolean } | null>(null) + const [actions, setActions] = useState([]) + const [running, setRunning] = useState(null) + const [actionResult, setActionResult] = useState>({}) useEffect(() => { let alive = true @@ -71,6 +74,7 @@ function PluginSettingsForm({ id, name }: { id: string; name: string }) { .then(r => { if (!alive) return setFields(r.fields) + setActions(r.actions ?? []) const init: Record = {} for (const f of r.fields) { const v = r.config[f.key] @@ -84,8 +88,23 @@ function PluginSettingsForm({ id, name }: { id: string; name: string }) { }, [id]) const hasFields = (fields?.length ?? 0) > 0 - // Show the card if the plugin has user fields OR an OAuth connection to offer. - if (fields === null || (!hasFields && !oauth?.configured)) return null + // Show the card if the plugin has user fields, actions, OR an OAuth connection to offer. + if (fields === null || (!hasFields && actions.length === 0 && !oauth?.configured)) return null + + // An action runs AS the caller, so it sees the values they just saved — run the save + // first if the form is dirty would be nicer, but keeping it explicit is less surprising. + const runAction = async (a: PluginAction) => { + if (a.danger && !window.confirm(t('settings.plugins.actions.confirm'))) return + setRunning(a.key) + try { + const res = await pluginsApi.runAction(id, a.key) + setActionResult(prev => ({ ...prev, [a.key]: res })) + } catch { + setActionResult(prev => ({ ...prev, [a.key]: { ok: false, message: t('common.error') } })) + } finally { + setRunning(null) + } + } const save = async () => { setSaving(true) @@ -164,6 +183,38 @@ function PluginSettingsForm({ id, name }: { id: string; name: string }) { {t('common.save')} )} + {actions.length > 0 && ( +
+ + {t('settings.plugins.actions')} + +
+ {actions.map(a => { + const res = actionResult[a.key] + return ( +
+ + {a.hint && {a.hint}} + {res && ( + + {res.message || (res.ok ? t('common.success') : t('common.error'))} + + )} +
+ ) + })} +
+
+ )}
) diff --git a/client/src/pages/AdminPage.test.tsx b/client/src/pages/AdminPage.test.tsx index d9afb7e92..f9be188c5 100644 --- a/client/src/pages/AdminPage.test.tsx +++ b/client/src/pages/AdminPage.test.tsx @@ -1,11 +1,10 @@ -import { describe, it, expect, beforeEach } from 'vitest'; -import { render, screen, waitFor, fireEvent, within } from '../../tests/helpers/render'; import { http, HttpResponse } from 'msw'; +import { beforeEach, describe, expect, it } from 'vitest'; +import { buildAdmin } from '../../tests/helpers/factories'; import { server } from '../../tests/helpers/msw/server'; +import { fireEvent, render, screen, waitFor, within } from '../../tests/helpers/render'; import { resetAllStores, seedStore } from '../../tests/helpers/store'; -import { buildUser, buildAdmin } from '../../tests/helpers/factories'; import { useAuthStore } from '../store/authStore'; -import { useAddonStore } from '../store/addonStore'; import AdminPage from './AdminPage'; // Mock heavy sub-panels to focus on page-level concerns @@ -145,7 +144,7 @@ describe('AdminPage', () => { }), http.get('/api/admin/stats', () => { return HttpResponse.json({ error: 'Forbidden' }, { status: 403 }); - }), + }) ); seedStore(useAuthStore, { @@ -237,7 +236,7 @@ describe('AdminPage', () => { await waitFor(() => { expect(screen.getByText('42')).toBeInTheDocument(); // totalPlaces — unique on page - expect(screen.getByText('8')).toBeInTheDocument(); // totalFiles — unique on page + expect(screen.getByText('8')).toBeInTheDocument(); // totalFiles — unique on page }); }); }); @@ -302,7 +301,7 @@ describe('AdminPage', () => { server.use( http.get('/api/admin/version-check', () => { return HttpResponse.json({ update_available: true, latest: '9.9.9', current: '1.0.0' }); - }), + }) ); seedStore(useAuthStore, { isAuthenticated: true, user: buildAdmin() }); @@ -330,7 +329,7 @@ describe('AdminPage', () => { return HttpResponse.json({ addons: [{ id: 'mcp', name: 'MCP Tokens', type: 'mcp', icon: '', enabled: true }], }); - }), + }) ); seedStore(useAuthStore, { isAuthenticated: true, user: buildAdmin() }); @@ -347,9 +346,9 @@ describe('AdminPage', () => { let capturedBody: Record | null = null; server.use( http.put('/api/auth/app-settings', async ({ request }) => { - capturedBody = await request.json() as Record; + capturedBody = (await request.json()) as Record; return HttpResponse.json({}); - }), + }) ); seedStore(useAuthStore, { isAuthenticated: true, user: buildAdmin() }); @@ -462,9 +461,9 @@ describe('AdminPage', () => { let capturedBody: Record | null = null; server.use( http.put('/api/auth/app-settings', async ({ request }) => { - capturedBody = await request.json() as Record; + capturedBody = (await request.json()) as Record; return HttpResponse.json({}); - }), + }) ); seedStore(useAuthStore, { isAuthenticated: true, user: buildAdmin() }); @@ -543,9 +542,18 @@ describe('AdminPage', () => { server.use( http.get('/api/admin/invites', () => { return HttpResponse.json({ - invites: [{ id: 1, token: 'abcdef123456789', max_uses: 5, used_count: 0, expires_at: null, created_by_name: 'admin' }], + invites: [ + { + id: 1, + token: 'abcdef123456789', + max_uses: 5, + used_count: 0, + expires_at: null, + created_by_name: 'admin', + }, + ], }); - }), + }) ); seedStore(useAuthStore, { isAuthenticated: true, user: buildAdmin() }); @@ -573,9 +581,18 @@ describe('AdminPage', () => { server.use( http.get('/api/admin/invites', () => { return HttpResponse.json({ - invites: [{ id: 1, token: 'abcdef123456789', max_uses: 5, used_count: 0, expires_at: null, created_by_name: 'admin' }], + invites: [ + { + id: 1, + token: 'abcdef123456789', + max_uses: 5, + used_count: 0, + expires_at: null, + created_by_name: 'admin', + }, + ], }); - }), + }) ); seedStore(useAuthStore, { isAuthenticated: true, user: buildAdmin() }); @@ -612,11 +629,26 @@ describe('AdminPage', () => { http.get('/api/admin/notification-preferences', () => { return HttpResponse.json({ event_types: ['version_available'], - available_channels: { inapp: true, email: true }, + channels: [ + { + id: 'inapp', + source: 'builtin', + labelKey: 'settings.notificationPreferences.inapp', + active: true, + configured: true, + }, + { + id: 'email', + source: 'builtin', + labelKey: 'settings.notificationPreferences.email', + active: true, + configured: true, + }, + ], implemented_combos: { version_available: ['inapp', 'email'] }, preferences: { version_available: { inapp: true, email: true } }, }); - }), + }) ); seedStore(useAuthStore, { isAuthenticated: true, user: buildAdmin() }); @@ -640,7 +672,7 @@ describe('AdminPage', () => { return HttpResponse.json({ addons: [{ id: 'mcp', name: 'MCP Tokens', type: 'mcp', icon: '', enabled: true }], }); - }), + }) ); seedStore(useAuthStore, { isAuthenticated: true, user: buildAdmin() }); @@ -659,7 +691,7 @@ describe('AdminPage', () => { server.use( http.get('/api/admin/version-check', () => { return HttpResponse.json({ update_available: true, latest: '9.9.9', current: '1.0.0' }); - }), + }) ); seedStore(useAuthStore, { isAuthenticated: true, user: buildAdmin() }); @@ -723,9 +755,9 @@ describe('AdminPage', () => { let capturedBody: Record | null = null; server.use( http.put('/api/auth/app-settings', async ({ request }) => { - capturedBody = await request.json() as Record; + capturedBody = (await request.json()) as Record; return HttpResponse.json({}); - }), + }) ); seedStore(useAuthStore, { isAuthenticated: true, user: buildAdmin() }); @@ -755,7 +787,7 @@ describe('AdminPage', () => { server.use( http.put('/api/admin/oidc', async ({ request }) => { return HttpResponse.json(await request.json()); - }), + }) ); seedStore(useAuthStore, { isAuthenticated: true, user: buildAdmin() }); @@ -788,9 +820,9 @@ describe('AdminPage', () => { let capturedBody: Record | null = null; server.use( http.put('/api/auth/app-settings', async ({ request }) => { - capturedBody = await request.json() as Record; + capturedBody = (await request.json()) as Record; return HttpResponse.json({}); - }), + }) ); seedStore(useAuthStore, { isAuthenticated: true, user: buildAdmin() }); @@ -816,16 +848,16 @@ describe('AdminPage', () => { let capturedBody: Record | null = null; server.use( http.put('/api/auth/app-settings', async ({ request }) => { - capturedBody = await request.json() as Record; + capturedBody = (await request.json()) as Record; return HttpResponse.json({}); - }), + }) ); // Start with email enabled by seeding smtpValues server.use( http.get('/api/auth/app-settings', () => { return HttpResponse.json({ notification_channels: 'email', smtp_host: 'mail.example.com' }); - }), + }) ); seedStore(useAuthStore, { isAuthenticated: true, user: buildAdmin() }); @@ -882,7 +914,7 @@ describe('AdminPage', () => { server.use( http.get('/api/admin/version-check', () => { return HttpResponse.json({ update_available: true, latest: '9.9.9', current: '1.0.0' }); - }), + }) ); seedStore(useAuthStore, { isAuthenticated: true, user: buildAdmin() }); @@ -942,8 +974,9 @@ describe('AdminPage', () => { target: { value: 'alice-new@example.com' }, }); - expect((screen.getByDisplayValue('alice-new@example.com') as HTMLInputElement).value) - .toBe('alice-new@example.com'); + expect((screen.getByDisplayValue('alice-new@example.com') as HTMLInputElement).value).toBe( + 'alice-new@example.com' + ); }); }); @@ -954,7 +987,7 @@ describe('AdminPage', () => { http.put('/api/auth/me/api-keys', async ({ request }) => { capturedBody = await request.json(); return HttpResponse.json({ success: true }); - }), + }) ); seedStore(useAuthStore, { isAuthenticated: true, user: buildAdmin() }); @@ -986,7 +1019,7 @@ describe('AdminPage', () => { http.put('/api/auth/me/api-keys', async ({ request }) => { capturedBody = (await request.json()) as Record; return HttpResponse.json({ success: true }); - }), + }) ); seedStore(useAuthStore, { isAuthenticated: true, user: buildAdmin() }); @@ -1018,7 +1051,7 @@ describe('AdminPage', () => { }), http.get('/api/auth/validate-keys', () => { return HttpResponse.json({ maps: true, weather: false }); - }), + }) ); seedStore(useAuthStore, { isAuthenticated: true, user: buildAdmin() }); @@ -1079,7 +1112,7 @@ describe('AdminPage', () => { http.delete('/api/admin/users/:id', ({ params }) => { deletedId = params.id as string; return HttpResponse.json({ success: true }); - }), + }) ); seedStore(useAuthStore, { isAuthenticated: true, user: buildAdmin() }); @@ -1109,7 +1142,7 @@ describe('AdminPage', () => { http.post('/api/admin/rotate-jwt-secret', () => { rotateCalled = true; return HttpResponse.json({ success: true }); - }), + }) ); seedStore(useAuthStore, { isAuthenticated: true, user: buildAdmin() }); @@ -1142,7 +1175,7 @@ describe('AdminPage', () => { smtp_host: 'mail.example.com', smtp_skip_tls_verify: 'false', }); - }), + }) ); seedStore(useAuthStore, { isAuthenticated: true, user: buildAdmin() }); @@ -1186,7 +1219,7 @@ describe('AdminPage', () => { http.post('/api/notifications/test-smtp', () => { testSmtpCalled = true; return HttpResponse.json({ success: true }); - }), + }) ); seedStore(useAuthStore, { isAuthenticated: true, user: buildAdmin() }); @@ -1223,7 +1256,7 @@ describe('AdminPage', () => { http.put('/api/auth/app-settings', async () => { appSettingsCalled = true; return HttpResponse.json({}); - }), + }) ); seedStore(useAuthStore, { isAuthenticated: true, user: buildAdmin() }); @@ -1260,7 +1293,7 @@ describe('AdminPage', () => { http.put('/api/auth/app-settings', async ({ request }) => { savedPayload = await request.json(); return HttpResponse.json({}); - }), + }) ); seedStore(useAuthStore, { isAuthenticated: true, user: buildAdmin() }); @@ -1292,7 +1325,15 @@ describe('AdminPage', () => { http.get('/api/admin/notification-preferences', () => { return HttpResponse.json({ event_types: ['trip.created'], - available_channels: { email: true }, + channels: [ + { + id: 'email', + source: 'builtin', + labelKey: 'settings.notificationPreferences.email', + active: true, + configured: true, + }, + ], implemented_combos: { 'trip.created': ['email'] }, preferences: { 'trip.created': { email: true } }, }); @@ -1300,7 +1341,7 @@ describe('AdminPage', () => { http.put('/api/admin/notification-preferences', async () => { prefUpdateCalled = true; return HttpResponse.json({ success: true }); - }), + }) ); seedStore(useAuthStore, { isAuthenticated: true, user: buildAdmin() }); @@ -1346,7 +1387,9 @@ describe('AdminPage', () => { // Discovery URL field const discoveryInput = within(oidcCard!).getByPlaceholderText(/openid-configuration/i); - fireEvent.change(discoveryInput, { target: { value: 'https://auth.example.com/.well-known/openid-configuration' } }); + fireEvent.change(discoveryInput, { + target: { value: 'https://auth.example.com/.well-known/openid-configuration' }, + }); // Client ID field const clientIdLabel = within(oidcCard!).getByText('Client ID'); diff --git a/client/src/pages/admin/AdminNotificationsPanel.tsx b/client/src/pages/admin/AdminNotificationsPanel.tsx index 4d714719a..a78dbd3a6 100644 --- a/client/src/pages/admin/AdminNotificationsPanel.tsx +++ b/client/src/pages/admin/AdminNotificationsPanel.tsx @@ -15,8 +15,11 @@ export default function AdminNotificationsPanel({ t, toast }: { t: (k: string) = if (!matrix) return

Loading…

+ // Admin-scoped events only ever go out over the built-in channels (plugin channels + // are user-scoped), so this list stays explicit rather than server-driven. + const isActive = (id: string) => matrix.channels?.some((c: { id: string; active: boolean }) => c.id === id && c.active) ?? false const visibleChannels = (['inapp', 'email', 'webhook', 'ntfy'] as const).filter(ch => { - if (!matrix.available_channels[ch]) return false + if (!isActive(ch)) return false return matrix.event_types.some((evt: string) => matrix.implemented_combos[evt]?.includes(ch)) }) diff --git a/client/src/pages/admin/AdminNotificationsTab.tsx b/client/src/pages/admin/AdminNotificationsTab.tsx index 14ccfc7f4..532020163 100644 --- a/client/src/pages/admin/AdminNotificationsTab.tsx +++ b/client/src/pages/admin/AdminNotificationsTab.tsx @@ -26,13 +26,16 @@ export default function AdminNotificationsTab({ admin, t }: AdminNotificationsTa const tripRemindersActive = smtpValues.notify_trip_reminder !== 'false' const setChannels = async (email: boolean, webhook: boolean, ntfy: boolean) => { - const chans = [email && 'email', webhook && 'webhook', ntfy && 'ntfy'].filter(Boolean).join(',') || 'none' + // Preserve any id this toggle doesn't know about instead of rebuilding the CSV from + // just these three booleans — that used to silently DROP anything else stored here. + const others = activeChans.filter((c: string) => c !== 'email' && c !== 'webhook' && c !== 'ntfy') + const chans = [email && 'email', webhook && 'webhook', ntfy && 'ntfy', ...others].filter(Boolean).join(',') || 'none' setSmtpValues(prev => ({ ...prev, notification_channels: chans })) try { await authApi.updateAppSettings({ notification_channels: chans }) } catch { // Revert state on failure - const reverted = [emailActive && 'email', webhookActive && 'webhook', ntfyActive && 'ntfy'].filter(Boolean).join(',') || 'none' + const reverted = [emailActive && 'email', webhookActive && 'webhook', ntfyActive && 'ntfy', ...others].filter(Boolean).join(',') || 'none' setSmtpValues(prev => ({ ...prev, notification_channels: reverted })) toast.error(t('common.error')) } diff --git a/client/src/pages/settings/useSettings.ts b/client/src/pages/settings/useSettings.ts index 0d92cbd29..e5cb74ba4 100644 --- a/client/src/pages/settings/useSettings.ts +++ b/client/src/pages/settings/useSettings.ts @@ -35,5 +35,13 @@ export function useSettings() { } }, [searchParams]) + // Deep link into a tab: /settings?tab=plugins. Lets one tab point at another — + // e.g. an unconfigured plugin notification channel sends you to where its + // credentials actually live, instead of just naming the place. + useEffect(() => { + const tab = searchParams.get('tab') + if (tab) setActiveTab(tab) + }, [searchParams]) + return { hasIntegrations, appVersion, activeTab, setActiveTab } } diff --git a/plugin-sdk/src/cli/create.ts b/plugin-sdk/src/cli/create.ts index b449797ea..f9ad7d208 100644 --- a/plugin-sdk/src/cli/create.ts +++ b/plugin-sdk/src/cli/create.ts @@ -36,24 +36,48 @@ export interface ScaffoldOptions { requiredAddons?: string[]; /** Other plugins this one depends on, each pinned by a semver range. */ pluginDependencies?: Array<{ id: string; version: string }>; + /** + * Which starter to generate. This is NOT the manifest `type` — a notification + * channel is a plain `integration` that implements the `notificationChannel` hook. + */ + template?: 'blank' | 'notification-channel'; } +export const TEMPLATES = ['blank', 'notification-channel'] as const; + export function scaffold(name: string, type: string, targetDir: string, opts: ScaffoldOptions = {}): void { if (!/^[a-z][a-z0-9-]{2,39}$/.test(name)) throw new Error(`invalid plugin id "${name}" (lowercase slug, 3–40 chars)`); if (!['integration', 'page', 'widget', 'trip-page'].includes(type)) throw new Error(`invalid type "${type}"`); + const template = opts.template ?? 'blank'; + if (!TEMPLATES.includes(template)) throw new Error(`invalid template "${template}"`); + const isChannel = template === 'notification-channel'; + // A notification channel is server-only by construction: it takes a rendered + // message and pushes it somewhere. Nothing else makes sense. + if (isChannel && type !== 'integration') throw new Error('the notification-channel template requires type "integration"'); + const root = path.join(targetDir, name); if (fs.existsSync(root)) throw new Error(`${root} already exists`); fs.mkdirSync(path.join(root, 'server'), { recursive: true }); - const perms = opts.permissions?.length ? opts.permissions : ['db:own']; + const displayName = name.replace(/-/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase()); + let perms = opts.permissions?.length ? opts.permissions : ['db:own']; + let egress = opts.egress ?? []; + if (isChannel) { + // The two grants a channel cannot work without, plus a placeholder host the + // author swaps for their own — the manifest rejects http:outbound with no egress. + if (!egress.length) egress = ['gotify.example.com']; + const required = ['hook:notification-channel', ...egress.map((h) => `http:outbound:${h}`)]; + perms = [...new Set([...perms.filter((p) => p !== 'db:own'), ...required])]; + } + const manifest: Record = { id: name, - name: name.replace(/-/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase()), + name: displayName, version: '1.0.0', apiVersion: 1, author: opts.author || 'Your Name', - description: opts.description || 'Describe what your plugin does.', + description: opts.description || (isChannel ? `Deliver TREK notifications over ${displayName}.` : 'Describe what your plugin does.'), type, trek: '>=3.2.1 <4.0.0', nativeModules: false, @@ -64,16 +88,48 @@ export function scaffold(name: string, type: string, targetDir: string, opts: Sc requiredAddons: opts.requiredAddons ?? [], pluginDependencies: opts.pluginDependencies ?? [], }; - if (opts.egress?.length) manifest.egress = opts.egress; + if (egress.length) manifest.egress = egress; + // A notification channel usually targets a SELF-HOSTED service, whose hostname the + // author cannot know at publish time. Declaring operatorEgress lets the admin add the + // real host after install; without it the plugin only ever reaches the hosts above. + if (isChannel) manifest.operatorEgress = true; + // A settings-page button so the user can verify their credentials without waiting for + // a real notification. Actions are USER-INITIATED, so ctx.settings.get() works inside. + if (isChannel) manifest.actions = [{ key: 'testConnection', label: 'Test connection' }]; // Routes are declared in server/index.js (definePlugin), NOT the manifest — the // host ignores a manifest `routes`. A `page` plugin gets its nav entry // automatically, so there's no `capabilities.nav` either; only `widget` carries a // capability worth scaffolding. - if (type === 'widget') manifest.capabilities = { widget: { title: manifest.name, defaultSize: 'medium' } }; + if (type === 'widget') manifest.capabilities = { widget: { title: displayName, defaultSize: 'medium' } }; + if (isChannel) { + manifest.capabilities = { notificationChannel: { title: displayName } }; + // The recipient's own credential. `scope: 'user'` is what makes it per-user, and + // the host hands the decrypted value to your hook as `config` at send time. + manifest.settings = [ + { + key: 'serverUrl', + label: 'Server URL', + input_type: 'text', + placeholder: 'https://gotify.example.com', + hint: 'Your Gotify server. Must match an entry in the manifest `egress` list.', + required: true, + scope: 'user', + }, + { + key: 'appToken', + label: 'App token', + input_type: 'text', + hint: 'Create an application in Gotify and paste its token here.', + required: true, + secret: true, + scope: 'user', + }, + ]; + } fs.writeFileSync(path.join(root, 'trek-plugin.json'), JSON.stringify(manifest, null, 2) + '\n'); - fs.writeFileSync(path.join(root, 'server', 'index.js'), SERVER_JS(perms.includes('db:own'))); - fs.writeFileSync(path.join(root, 'README.md'), readme(name, opts.description ?? '> One sentence: what this plugin does.', perms)); + fs.writeFileSync(path.join(root, 'server', 'index.js'), isChannel ? CHANNEL_JS : SERVER_JS(perms.includes('db:own'))); + fs.writeFileSync(path.join(root, 'README.md'), readme(name, opts.description ?? (isChannel ? `> Deliver your TREK notifications to ${displayName}.` : '> One sentence: what this plugin does.'), perms)); // `type: commonjs` pins how the entry is parsed everywhere (dev, tests, TREK); // the SDK is a devDependency ONLY (types + mock host) — at runtime both the // dev server and TREK inject it, so it is never vendored into the artifact. @@ -91,6 +147,69 @@ export function scaffold(name: string, type: string, targetDir: string, opts: Sc } } +const CHANNEL_JS = `// A TREK notification channel — runs in an isolated child process. +// +// TREK renders every notification into the recipient's language and hands it to you +// ready to send. You never touch i18n, and you never pick a recipient: the host does +// that, then calls you once per recipient who has this channel switched on. +// +// This hook is HOST-initiated, so there is NO acting user while it runs: +// - ctx.settings.get() returns undefined here (it resolves against the acting user) +// - trip reads are refused +// The recipient's own settings — the fields you declared with scope: 'user' in +// trek-plugin.json — arrive DECRYPTED as the \`config\` argument. That is the only way +// to reach them, and it is why a channel plugin cannot enumerate anyone's trips. +const { definePlugin } = require('trek-plugin-sdk'); + +async function push(config, title, message) { + // Every host you call must be listed in the manifest's \`egress\` array AND granted + // via http:outbound:, or the child's network guard blocks the request. + const res = await fetch(String(config.serverUrl).replace(/\\/+$/, '') + '/message', { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'X-Gotify-Key': String(config.appToken), + }, + body: JSON.stringify({ title, message, priority: 5 }), + }); + // Throw on failure: the host logs it and isolates it, so one dead channel can never + // stop the others (or the in-app notification) from being delivered. + if (!res.ok) throw new Error('Gotify responded ' + res.status + ' ' + (await res.text().catch(() => ''))); +} + +module.exports = definePlugin({ + // A button on your settings page. USER-INITIATED, so unlike the channel hook there IS + // an acting user (whoever clicked) — ctx.settings.get() returns THEIR values, which is + // what makes "test my credentials" possible. + actions: { + async testConnection(ctx) { + const config = { + serverUrl: await ctx.settings.get('serverUrl'), + appToken: await ctx.settings.get('appToken'), + }; + await push(config, 'TREK', 'Test notification. If you can read this, your channel works.'); + return { ok: true, message: 'Connected.' }; + }, + }, + + hooks: { + notificationChannel: { + async send(msg, config, ctx) { + // msg = { event, title, body, url?, tripName? } — already localized. + const body = msg.url ? msg.body + '\\n\\n' + msg.url : msg.body; + await push(config, msg.title, body); + ctx.log.info('delivered ' + msg.event); + }, + + // Optional — backs the "Send test" button in the user's notification settings. + async test(config) { + await push(config, 'TREK', 'Test notification. If you can read this, your channel works.'); + }, + }, + }, +}); +`; + const SERVER_JS = (has_db: boolean) => `// Built plugin entry — runs in an isolated child process. const { definePlugin } = require('trek-plugin-sdk'); @@ -250,6 +369,20 @@ export async function interactiveScaffold(defaultDir: string, presetName?: strin ], }); + // A starter, not a manifest type — a notification channel is a plain `integration` + // that implements the notificationChannel hook, so it only makes sense to offer here. + let template: 'blank' | 'notification-channel' = 'blank'; + if (type === 'integration') { + template = await promptSelect<'blank' | 'notification-channel'>({ + message: 'Start from a template?', + initialValue: 'blank', + options: [ + { value: 'blank', label: 'blank', hint: 'an empty plugin with one example route' }, + { value: 'notification-channel', label: 'notification channel', hint: 'deliver TREK notifications to Gotify/Pushover/…' }, + ], + }); + } + const author = await promptText({ message: 'Author', placeholder: 'Your Name', defaultValue: 'Your Name' }); const description = await promptText({ message: 'One-line description', @@ -265,10 +398,14 @@ export async function interactiveScaffold(defaultDir: string, presetName?: strin }); let egress: string[] | undefined; - if (permissions.includes('http:outbound')) { + // A channel has to reach its service, so it always needs an egress host — the + // scaffold turns each one into a matching http:outbound: grant. + if (permissions.includes('http:outbound') || template === 'notification-channel') { const raw = await promptText({ - message: 'External hosts it may call (comma-separated)', - placeholder: 'api.example.com, api.other.com', + message: template === 'notification-channel' + ? 'Which host does your notification service live on?' + : 'External hosts it may call (comma-separated)', + placeholder: template === 'notification-channel' ? 'gotify.example.com' : 'api.example.com, api.other.com', validate: (v) => ((v ?? '').split(',').map((s) => s.trim()).filter(Boolean).length ? undefined : 'list at least one host'), }); egress = raw.split(',').map((s) => s.trim()).filter(Boolean); @@ -285,6 +422,7 @@ export async function interactiveScaffold(defaultDir: string, presetName?: strin [ `id ${id}`, `type ${type}`, + template !== 'blank' ? `template ${template}` : undefined, `location ${dest}`, `author ${author}`, `permissions ${permissions.join(', ') || '(none)'}`, @@ -300,7 +438,7 @@ export async function interactiveScaffold(defaultDir: string, presetName?: strin process.exit(0); } - scaffold(id, type, parentDir, { author, description, permissions, egress, requiredAddons }); + scaffold(id, type, parentDir, { author, description, permissions, egress, requiredAddons, template }); logSuccess(`Created ${dest}`); if (!insideGitRepo(parentDir)) { @@ -339,12 +477,14 @@ if (process.argv[1] && process.argv[1].endsWith('create.js')) { const name = args.find((a: string) => !a.startsWith('-')); const typeIdx = args.indexOf('--type'); const type = typeIdx >= 0 ? args[typeIdx + 1] : 'integration'; + const tplIdx = args.indexOf('--template'); + const template = (tplIdx >= 0 ? args[tplIdx + 1] : 'blank') as ScaffoldOptions['template']; if (!name) { - console.error('usage: create-trek-plugin [--type integration|page|widget|trip-page]'); + console.error('usage: create-trek-plugin [--type integration|page|widget|trip-page] [--template blank|notification-channel]'); process.exit(2); } try { - scaffold(name, type, process.cwd()); + scaffold(name, type, process.cwd(), { template }); console.log(`Created ${name}/ — fill in the README, build server/index.js, then \`npx trek-plugin-sdk validate ${name}\`.`); } catch (e) { console.error(e instanceof Error ? e.message : e); diff --git a/plugin-sdk/src/cli/entry.ts b/plugin-sdk/src/cli/entry.ts index ba508a6dd..b7b471ee5 100644 --- a/plugin-sdk/src/cli/entry.ts +++ b/plugin-sdk/src/cli/entry.ts @@ -19,7 +19,7 @@ import { readJsonFile } from './json.js'; interface Version { version: string; gitTag: string; commitSha: string; downloadUrl: string; sha256: string; minTrekVersion: string; size: number; apiVersion: number; - nativeModules: false; publishedAt: string; signature?: string; + nativeModules: false; operatorEgress?: boolean; publishedAt: string; signature?: string; } interface Entry { id: string; name: string; author: string; description: string; repo: string; @@ -68,6 +68,10 @@ export function buildEntry(opts: { nativeModules: false, publishedAt: opts.now, }; + // Mirror the manifest's operatorEgress onto the entry — CI parity-checks it, because the + // flag says the egress[] list is NOT the plugin's full network reach (an admin may add + // hosts after install). Only emitted when true, so ordinary entries stay unchanged. + if (manifest.operatorEgress === true) version.operatorEgress = true; // Optional author signature over the exact artifact bytes. let authorPublicKey: string | undefined; diff --git a/plugin-sdk/src/cli/preflight.ts b/plugin-sdk/src/cli/preflight.ts index aabf982b9..9adfe0bd9 100644 --- a/plugin-sdk/src/cli/preflight.ts +++ b/plugin-sdk/src/cli/preflight.ts @@ -18,7 +18,7 @@ const NATIVE_RE = /(^|\/)[^/]+\.node$|(^|\/)binding\.gyp$|(^|\/)prebuilds?\//i; export interface EntryVersion { version: string; gitTag: string; commitSha: string; downloadUrl: string; - sha256: string; size: number; apiVersion: number; nativeModules?: boolean; signature?: string; + sha256: string; size: number; apiVersion: number; nativeModules?: boolean; operatorEgress?: boolean; signature?: string; } export interface Entry { id: string; name: string; type: string; repo: string; authorPublicKey?: string; versions: EntryVersion[]; @@ -81,6 +81,12 @@ export async function preflight(entry: Entry, opts: { all?: boolean } = {}): Pro if (m.type !== entry.type) fail(`${tag}: manifest type "${m.type}" != entry "${entry.type}"`); if (m.apiVersion !== v.apiVersion) fail(`${tag}: manifest apiVersion ${m.apiVersion} != entry ${v.apiVersion}`); if (m.nativeModules === true) fail(`${tag}: manifest declares nativeModules:true (forbidden)`); + // Mirrors the registry's operatorEgress parity check (validate-entry.mjs): the entry + // must not understate the plugin's network reach. + const mOperatorEgress = (m as { operatorEgress?: unknown }).operatorEgress === true; + if (mOperatorEgress !== (v.operatorEgress === true)) { + fail(`${tag}: manifest operatorEgress ${mOperatorEgress} != entry ${v.operatorEgress === true}`); + } manifestPerms = Array.isArray(m.permissions) ? (m.permissions as string[]) : []; if (manifestPerms.some((p) => p === 'http:outbound' || p.startsWith('http:outbound:'))) { const egress = Array.isArray(m.egress) ? (m.egress as string[]) : []; diff --git a/plugin-sdk/src/cli/ui.ts b/plugin-sdk/src/cli/ui.ts index 37903f94f..ca67a1f02 100644 --- a/plugin-sdk/src/cli/ui.ts +++ b/plugin-sdk/src/cli/ui.ts @@ -93,6 +93,7 @@ export const PERMISSION_CATALOG: { value: string; label: string; hint: string }[ { value: 'ws:broadcast:user', label: 'ws:broadcast:user', hint: 'Push realtime events to a single user' }, { value: 'hook:photo-provider', label: 'hook:photo-provider', hint: 'Supply place photos to TREK' }, { value: 'hook:calendar-source', label: 'hook:calendar-source', hint: 'Supply calendar events to TREK' }, + { value: 'hook:notification-channel', label: 'hook:notification-channel', hint: 'Deliver TREK notifications over your own channel' }, { value: 'hook:place-detail-provider', label: 'hook:place-detail-provider', hint: 'Contribute extra details (reviews, ratings, links) to a place' }, { value: 'hook:trip-warning-provider', label: 'hook:trip-warning-provider', hint: 'Raise validation warnings on a trip (shown in the planner)' }, { value: 'http:outbound', label: 'http:outbound', hint: 'Call external HTTP hosts (needs an egress allow-list)' }, diff --git a/plugin-sdk/src/index.ts b/plugin-sdk/src/index.ts index 1a1a7d56d..ac3f88e43 100644 --- a/plugin-sdk/src/index.ts +++ b/plugin-sdk/src/index.ts @@ -358,6 +358,49 @@ export interface PhotoProvider { search(query: string, opts: { page: number; limit: number }, ctx: PluginContext): Promise<{ photos: Photo[]; total: number; hasMore: boolean }>; getById(id: string, ctx: PluginContext): Promise; } +/** + * A notification, already rendered by the host into the recipient's language. + * A channel plugin never touches i18n — it only delivers. + */ +export interface NotificationMessage { + /** The TREK event that produced this, e.g. `trip_invite`. */ + event: string; + title: string; + body: string; + /** Absolute deep link back into TREK, when the event has one. */ + url?: string; + tripName?: string; +} + +/** + * Deliver TREK notifications over a channel TREK doesn't ship (Gotify, Pushover, …). + * Needs `hook:notification-channel`. + * + * Unlike every other hook, this one is HOST-initiated for an arbitrary recipient, so + * it runs WITHOUT an acting user: `ctx.settings.get()` returns undefined here and trip + * reads are refused. The recipient's own `scope:'user'` settings are decrypted by the + * host and handed to you as `config` instead — that is the only way to reach them, and + * it is why a channel plugin cannot enumerate the recipient's trips. + * + * Declare the channel in your manifest: + * "capabilities": { "notificationChannel": { "title": "Gotify" } } + */ +export interface NotificationChannel { + send(msg: NotificationMessage, config: Record, ctx: PluginContext): Promise; + /** Backs the "Send test" button in the user's notification settings. */ + test?(config: Record, ctx: PluginContext): Promise; +} + +/** + * Result of a settings-page action. `message` is shown to the user beside the button + * (bounded + emoji-stripped host-side); throwing is equivalent to `{ ok: false }` with + * the error message. + */ +export interface PluginActionResult { + ok?: boolean; + message?: string; +} + export interface CalendarEvent { id: string; title: string; @@ -537,6 +580,16 @@ export interface PluginDefinition { * JSON-serialisable value the host aggregates. Userless. Needs `hook:user-data`. */ exportUserData?(input: { userId: number }, ctx: PluginContext): Promise | unknown; events?: PluginEventSubscription[]; + /** + * Buttons on the plugin's own settings page ("Test connection", "Sync now"). The key + * must match an entry in the manifest's `actions`. + * + * USER-INITIATED, so unlike the notificationChannel hook there IS an acting user — the + * person who clicked. `ctx.settings.get()` returns THEIR value and trip reads are + * membership-checked against them, which is what makes a "test my credentials" button + * possible at all. + */ + actions?: Record Promise | PluginActionResult | void>; hooks?: { photoProvider?: PhotoProvider; calendarSource?: CalendarSource; @@ -548,6 +601,7 @@ export interface PluginDefinition { atlasLayerProvider?: AtlasLayerProvider; journalEntryProvider?: JournalEntryProvider; tripCardProvider?: TripCardProvider; + notificationChannel?: NotificationChannel; }; /** Functions exposed to dependents (names must match manifest `capabilities.provides`). */ exports?: Record; diff --git a/plugin-sdk/src/manifest.ts b/plugin-sdk/src/manifest.ts index 3074d4db0..d2f19f45a 100644 --- a/plugin-sdk/src/manifest.ts +++ b/plugin-sdk/src/manifest.ts @@ -46,6 +46,27 @@ export const KNOWN_ADDONS = [ // no spaces (mirrors the server manifest validator). const HOST_RE = /^(\*\.[a-z0-9-]+(\.[a-z0-9-]+)+|[a-z0-9-]+(\.[a-z0-9-]+)*)$/i; const TYPES = ['integration', 'page', 'widget', 'trip-page']; + +/** Mirrors the server's settings-key rules (install/manifest.ts). */ +const SETTING_KEY_RE = /^[a-zA-Z][a-zA-Z0-9_.-]{0,63}$/; +const RESERVED_SETTING_KEYS = new Set(['constructor', 'prototype', '__proto__']); + +/** + * Events a plugin notification channel may carry (mirrors the server's + * PLUGIN_CHANNEL_EVENTS). Admin-scoped and in-app-only events are excluded. + */ +export const CHANNEL_EVENTS = [ + 'trip_invite', + 'booking_change', + 'trip_reminder', + 'todo_due', + 'vacay_invite', + 'collection_invite', + 'photos_shared', + 'collab_message', + 'packing_tagged', + 'plugin_notification', +]; // Mirror of the server's KNOWN_PERMISSIONS (server envelope.ts) — the host hard-rejects // anything not in this list at activation, so validate must know the full set. const KNOWN_PERMISSIONS = [ @@ -63,7 +84,7 @@ const KNOWN_PERMISSIONS = [ 'ws:broadcast:trip', 'ws:broadcast:user', 'hook:photo-provider', 'hook:calendar-source', 'hook:place-detail-provider', 'hook:trip-warning-provider', 'hook:table-contributor', 'hook:map-marker-provider', 'hook:pdf-section-provider', 'hook:atlas-layer-provider', - 'hook:journal-entry-provider', 'hook:trip-card-provider', 'hook:user-data', + 'hook:journal-entry-provider', 'hook:trip-card-provider', 'hook:notification-channel', 'hook:user-data', 'events:subscribe', 'jobs:run', 'http:outbound', 'weather:read', 'rates:read', 'notify:send', 'ai:invoke', 'oauth:client', ]; @@ -103,7 +124,22 @@ export function validateManifest(raw: unknown): ValidationResult { if (egress.includes('*')) errors.push('egress[] must not contain a bare "*"'); for (const h of egress) if (!HOST_RE.test(h)) errors.push(`invalid egress host "${h}"`); - const capabilities = (m.capabilities ?? undefined) as { widget?: { slot?: unknown }; tripPage?: { replaces?: unknown; position?: unknown }; provides?: unknown; emits?: unknown } | undefined; + // A plugin whose target host only the OPERATOR knows (a self-hosted Gotify/ntfy). + // The admin adds the real hosts post-install; see Plugin-Development → Egress. + if (m.operatorEgress !== undefined && typeof m.operatorEgress !== 'boolean') { + errors.push('operatorEgress must be a boolean'); + } + if (m.operatorEgress === true && !permissions.some((p) => p === 'http:outbound' || p.startsWith('http:outbound:'))) { + errors.push('operatorEgress requires an http:outbound permission'); + } + + const capabilities = (m.capabilities ?? undefined) as { + widget?: { slot?: unknown }; + tripPage?: { replaces?: unknown; position?: unknown }; + notificationChannel?: { title?: unknown; events?: unknown }; + provides?: unknown; + emits?: unknown; + } | undefined; const widget = capabilities?.widget; if (widget?.slot !== undefined && widget.slot !== 'sidebar' && widget.slot !== 'hero' && widget.slot !== 'place-detail' && widget.slot !== 'day-detail' && widget.slot !== 'reservation-detail') { errors.push(`widget slot must be "sidebar", "hero", "place-detail", "day-detail" or "reservation-detail", got "${String(widget.slot)}"`); @@ -122,6 +158,59 @@ export function validateManifest(raw: unknown): ValidationResult { errors.push('capabilities.tripPage.position must be an integer between 0 and 50'); } } + // Mirrors the server's PLUGIN_CHANNEL_EVENTS. Admin-scoped events (version_available) + // and inapp-only ones are absent by design: a plugin channel never carries them. + const notificationChannel = capabilities?.notificationChannel; + if (notificationChannel !== undefined) { + if (!permissions.includes('hook:notification-channel')) { + errors.push('capabilities.notificationChannel requires the "hook:notification-channel" permission'); + } + if (notificationChannel.title !== undefined && typeof notificationChannel.title !== 'string') { + errors.push('capabilities.notificationChannel.title must be a string'); + } + if (notificationChannel.events !== undefined) { + if (!Array.isArray(notificationChannel.events)) errors.push('capabilities.notificationChannel.events must be an array'); + else for (const e of notificationChannel.events) { + if (typeof e !== 'string' || !CHANNEL_EVENTS.includes(e)) { + errors.push(`capabilities.notificationChannel.events: "${String(e)}" is not a plugin-deliverable event (${CHANNEL_EVENTS.join(', ')})`); + } + } + } + } + // Settings keys become JSON object keys in the plugin's stored config, so they are + // constrained (mirrors the server's SETTING_KEY_RE). `__proto__`/`constructor` would + // resolve off Object.prototype on read and make a required field look configured for + // a user who configured nothing. + if (Array.isArray(m.settings)) { + for (const s of m.settings as Array>) { + if (!s || typeof s !== 'object' || s.key === undefined) continue; + const key = String(s.key); + if (!key) continue; + if (!SETTING_KEY_RE.test(key) || RESERVED_SETTING_KEYS.has(key)) { + errors.push(`invalid settings key "${key}" (letters, digits, . _ - ; must start with a letter; 1–64 chars)`); + } + if (s.scope !== undefined && s.scope !== 'user' && s.scope !== 'instance') { + errors.push(`settings["${key}"].scope must be "user" or "instance"`); + } + } + } + // Settings-page action buttons ("Test connection"). Keys share the settings-key rules. + if (m.actions !== undefined) { + if (!Array.isArray(m.actions)) errors.push('actions must be an array'); + else { + if (m.actions.length > 8) errors.push('at most 8 actions'); + const seen = new Set(); + for (const a of m.actions as Array>) { + if (!a || typeof a !== 'object') { errors.push('each action must be an object'); continue; } + const key = String(a.key ?? ''); + if (!key) { errors.push('action must have a "key"'); continue; } + if (!SETTING_KEY_RE.test(key) || RESERVED_SETTING_KEYS.has(key)) errors.push(`invalid action key "${key}"`); + if (seen.has(key)) errors.push(`duplicate action "${key}"`); + seen.add(key); + if (a.label !== undefined && typeof a.label !== 'string') errors.push(`action "${key}" label must be a string`); + } + } + } validateCapabilityNames(capabilities?.provides, 'provides', errors); validateCapabilityNames(capabilities?.emits, 'emits', errors); diff --git a/plugin-sdk/test/cli.test.ts b/plugin-sdk/test/cli.test.ts index 6e6728db9..c363f1128 100644 --- a/plugin-sdk/test/cli.test.ts +++ b/plugin-sdk/test/cli.test.ts @@ -40,7 +40,7 @@ describe('permission catalog', () => { 'db:write:places', 'db:write:days', 'db:write:itinerary', 'db:write:trips', 'db:meta', 'ws:broadcast:trip', 'ws:broadcast:user', - 'hook:photo-provider', 'hook:calendar-source', 'hook:place-detail-provider', 'hook:trip-warning-provider', 'http:outbound', + 'hook:photo-provider', 'hook:calendar-source', 'hook:notification-channel', 'hook:place-detail-provider', 'hook:trip-warning-provider', 'http:outbound', ]); for (const p of PERMISSION_CATALOG) expect(p.hint.length).toBeGreaterThan(0); // every option is described }); @@ -120,3 +120,92 @@ describe('validateManifest dependency rules', () => { expect(validateManifest({ ...base, pluginDependencies: [{ id: 'koffi', version: '*' }, { id: 'koffi', version: '^1' }] }).ok).toBe(false); }); }); + +describe('notification-channel template', () => { + let tmp: string; + beforeEach(() => { tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'chan-')); }); + afterEach(() => { fs.rmSync(tmp, { recursive: true, force: true }); }); + + function make(over: Parameters[3] = {}) { + scaffold('my-gotify', 'integration', tmp, { template: 'notification-channel', ...over }); + return JSON.parse(fs.readFileSync(path.join(tmp, 'my-gotify', 'trek-plugin.json'), 'utf8')); + } + + it('scaffolds a valid manifest with the hook grant, matching egress, and the capability', () => { + const m = make({ egress: ['gotify.example.com'] }); + expect(m.type).toBe('integration'); // a channel is NOT a new plugin type + expect(m.permissions).toContain('hook:notification-channel'); + expect(m.permissions).toContain('http:outbound:gotify.example.com'); + expect(m.egress).toEqual(['gotify.example.com']); + expect(m.capabilities.notificationChannel.title).toBe('My Gotify'); + expect(validateManifest(m).ok).toBe(true); + }); + + it('declares the per-user credential as a secret, required, user-scoped field', () => { + const m = make(); + const token = m.settings.find((s: { key: string }) => s.key === 'appToken'); + // These three flags are exactly what makes the host hand it to the hook as `config`. + expect(token.scope).toBe('user'); + expect(token.secret).toBe(true); + expect(token.required).toBe(true); + }); + + it('generates a server entry that implements the hook', () => { + make(); + const js = fs.readFileSync(path.join(tmp, 'my-gotify', 'server', 'index.js'), 'utf8'); + expect(js).toContain('notificationChannel'); + expect(js).toContain('async send(msg, config, ctx)'); + expect(js).toContain('config.appToken'); + + // The invariant is about WHERE ctx.settings.get() may be used, not whether: + // - the HOOK is userless, so it must read credentials from its `config` argument; + // - an ACTION is user-initiated, so it SHOULD read them via ctx.settings.get(). + const hookBody = js.slice(js.indexOf('notificationChannel:')); + expect(hookBody).not.toContain('ctx.settings.get'); + const actionBody = js.slice(js.indexOf('actions:'), js.indexOf('hooks:')); + expect(actionBody).toContain('await ctx.settings.get'); + }); + + it('scaffolds a Test connection action so the user can verify credentials', () => { + const m = make(); + expect(m.actions).toEqual([{ key: 'testConnection', label: 'Test connection' }]); + expect(validateManifest(m).ok).toBe(true); + }); + + it('rejects a malformed action key', () => { + const m = make(); + expect(validateManifest({ ...m, actions: [{ key: '__proto__', label: 'x' }] }).ok).toBe(false); + expect(validateManifest({ ...m, actions: [{ key: 'a', label: 'x' }, { key: 'a', label: 'y' }] }).ok).toBe(false); + }); + + it('is server-only: no client/ dir, and a UI type is refused', () => { + make(); + expect(fs.existsSync(path.join(tmp, 'my-gotify', 'client'))).toBe(false); + expect(() => scaffold('my-widget', 'widget', tmp, { template: 'notification-channel' })).toThrow(/requires type "integration"/); + }); +}); + +describe('capabilities.notificationChannel validation', () => { + const base = { + id: 'chan', name: 'Chan', version: '1.0.0', apiVersion: 1, type: 'integration', + nativeModules: false, permissions: ['hook:notification-channel'], + }; + + it('accepts a narrowed event list', () => { + const r = validateManifest({ ...base, capabilities: { notificationChannel: { events: ['trip_invite', 'booking_change'] } } }); + expect(r.ok).toBe(true); + }); + + it('rejects an event a plugin channel can never carry', () => { + // version_available is admin-scoped — it goes out over the admin's own credentials. + const r = validateManifest({ ...base, capabilities: { notificationChannel: { events: ['version_available'] } } }); + expect(r.ok).toBe(false); + expect(r.errors!.join(' ')).toMatch(/not a plugin-deliverable event/); + }); + + it('rejects the capability without the matching grant', () => { + const r = validateManifest({ ...base, permissions: ['db:own'], capabilities: { notificationChannel: { title: 'X' } } }); + expect(r.ok).toBe(false); + expect(r.errors!.join(' ')).toMatch(/requires the "hook:notification-channel" permission/); + }); +}); diff --git a/server/src/db/migrations.ts b/server/src/db/migrations.ts index d6100139d..a8e958772 100644 --- a/server/src/db/migrations.ts +++ b/server/src/db/migrations.ts @@ -3583,6 +3583,49 @@ function runMigrations(db: Database.Database): void { `); db.exec('CREATE INDEX IF NOT EXISTS idx_hidden_countries_user ON hidden_countries (user_id);'); }, + + // Operator-supplied egress hosts for a plugin (#plugins). + // A plugin's egress allow-list is fixed in its manifest at publish time, but a plugin + // that talks to a SELF-HOSTED service (Gotify, ntfy, …) cannot know the operator's + // hostname — so a community plugin could serve nobody. These rows let the ADMIN add + // hosts post-install; the runtime unions them into the child's allow-list at spawn. + // Consent stays with the admin (never the end user), exactly as for manifest egress. + () => { + db.exec(` + CREATE TABLE IF NOT EXISTS plugin_egress_hosts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + plugin_id TEXT NOT NULL, + host TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + UNIQUE (plugin_id, host) + ); + `); + db.exec('CREATE INDEX IF NOT EXISTS idx_plugin_egress_hosts_plugin ON plugin_egress_hosts (plugin_id);'); + // Whether the plugin DECLARED that it needs operator-supplied hosts. Only such a + // plugin may have hosts added — an admin can never widen egress for a plugin that + // never asked for it, so the install-time consent still bounds what's possible. + const columns = db.prepare("PRAGMA table_info('plugins')").all() as Array<{ name: string }>; + if (!columns.some((c) => c.name === 'operator_egress')) { + db.exec('ALTER TABLE plugins ADD COLUMN operator_egress INTEGER NOT NULL DEFAULT 0;'); + } + }, + + // Settings-page action buttons a plugin contributes ("Test connection", "Sync now"). + // Descriptors only — the handler lives in the plugin's code and is invoked host-side + // with the CLICKING user bound, so it can read that user's own settings. + () => { + db.exec(` + CREATE TABLE IF NOT EXISTS plugin_actions ( + plugin_id TEXT NOT NULL, + action_key TEXT NOT NULL, + label TEXT NOT NULL, + hint TEXT, + danger INTEGER NOT NULL DEFAULT 0, + sort_order INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (plugin_id, action_key) + ); + `); + }, ]; if (currentVersion < migrations.length) { diff --git a/server/src/nest/notifications/notifications.controller.ts b/server/src/nest/notifications/notifications.controller.ts index 14197f25f..eeae8f707 100644 --- a/server/src/nest/notifications/notifications.controller.ts +++ b/server/src/nest/notifications/notifications.controller.ts @@ -103,6 +103,18 @@ export class NotificationsController { return this.notifications.testNtfy({ topic: resolvedTopic, server: resolvedServer ?? null, token: resolvedToken }); } + /** + * Generic channel test, dispatched through the registry — this is how a plugin + * channel's "Send test" button works. The three routes above stay as they are: + * they carry bespoke masked-secret and admin-fallback resolution whose exact + * error bodies the client depends on, and folding them in here would risk that. + */ + @Post('test/:channelId') + @HttpCode(200) + async testChannel(@CurrentUser() user: User, @Param('channelId') channelId: string): Promise { + return this.notifications.testChannel(user.id, channelId); + } + @Get('in-app') listInApp( @CurrentUser() user: User, diff --git a/server/src/nest/notifications/notifications.service.ts b/server/src/nest/notifications/notifications.service.ts index 06e6c0801..abea15e3b 100644 --- a/server/src/nest/notifications/notifications.service.ts +++ b/server/src/nest/notifications/notifications.service.ts @@ -20,6 +20,7 @@ import { respondToBoolean, } from '../../services/inAppNotifications'; import { getPreferencesMatrix, setPreferences } from '../../services/notificationPreferencesService'; +import { getChannel } from '../../services/notifications/channelRegistry'; type NtfyConfig = ReturnType; type RespondResult = Awaited>; @@ -39,6 +40,19 @@ export class NotificationsService { return getPreferencesMatrix(userId, role, 'user'); } + /** Send a test notification over any registered channel (built-in or plugin). */ + async testChannel(userId: number, channelId: string): Promise { + const channel = getChannel(channelId); + if (!channel) return { success: false, error: 'Unknown channel' }; + if (!channel.test) return { success: false, error: 'This channel does not support test sends' }; + if (!channel.isConfiguredFor(userId)) return { success: false, error: 'Channel is not configured for this user' }; + try { + return await channel.test(userId); + } catch (e) { + return { success: false, error: e instanceof Error ? e.message : 'Unknown error' }; + } + } + setPreferences(userId: number, body: Parameters[1]): void { setPreferences(userId, body); } diff --git a/server/src/nest/plugins/install/discovery.ts b/server/src/nest/plugins/install/discovery.ts index df742caac..7e28100c1 100644 --- a/server/src/nest/plugins/install/discovery.ts +++ b/server/src/nest/plugins/install/discovery.ts @@ -67,17 +67,24 @@ function upsert(db: BetterSqlite3.Database, m: PluginManifest): void { if (existing) { db.prepare( `UPDATE plugins SET name = ?, description = ?, type = ?, icon = ?, version = ?, api_version = ?, - min_trek_version = ?, permissions = ?, capabilities = ?, dependencies = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?`, - ).run(m.name, m.description ?? null, m.type, m.icon ?? 'Blocks', m.version, m.apiVersion, m.minTrekVersion ?? null, JSON.stringify(m.permissions), JSON.stringify(m.capabilities), dependencies, m.id); + min_trek_version = ?, permissions = ?, capabilities = ?, dependencies = ?, operator_egress = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?`, + ).run(m.name, m.description ?? null, m.type, m.icon ?? 'Blocks', m.version, m.apiVersion, m.minTrekVersion ?? null, JSON.stringify(m.permissions), JSON.stringify(m.capabilities), dependencies, m.operatorEgress ? 1 : 0, m.id); } else { db.prepare( // granted_permissions '' (empty, not '[]') marks "never consented" so the // first activation is distinguishable from a plugin consented to zero perms. - `INSERT INTO plugins (id, name, description, type, icon, version, api_version, min_trek_version, permissions, capabilities, dependencies, granted_permissions, status) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, '', 'inactive')`, - ).run(m.id, m.name, m.description ?? null, m.type, m.icon ?? 'Blocks', m.version, m.apiVersion, m.minTrekVersion ?? null, JSON.stringify(m.permissions), JSON.stringify(m.capabilities), dependencies); + `INSERT INTO plugins (id, name, description, type, icon, version, api_version, min_trek_version, permissions, capabilities, dependencies, operator_egress, granted_permissions, status) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, '', 'inactive')`, + ).run(m.id, m.name, m.description ?? null, m.type, m.icon ?? 'Blocks', m.version, m.apiVersion, m.minTrekVersion ?? null, JSON.stringify(m.permissions), JSON.stringify(m.capabilities), dependencies, m.operatorEgress ? 1 : 0); } + // Refresh the settings-page action descriptors from the manifest. + db.prepare('DELETE FROM plugin_actions WHERE plugin_id = ?').run(m.id); + const insertAction = db.prepare( + 'INSERT INTO plugin_actions (plugin_id, action_key, label, hint, danger, sort_order) VALUES (?, ?, ?, ?, ?, ?)', + ); + m.actions.forEach((a, i) => insertAction.run(m.id, a.key, a.label, a.hint ?? null, a.danger ? 1 : 0, i)); + // Refresh the settings-field descriptors from the manifest. db.prepare('DELETE FROM plugin_settings_fields WHERE plugin_id = ?').run(m.id); const insert = db.prepare( diff --git a/server/src/nest/plugins/install/manifest.ts b/server/src/nest/plugins/install/manifest.ts index 37e3e63ee..d98cfc7c3 100644 --- a/server/src/nest/plugins/install/manifest.ts +++ b/server/src/nest/plugins/install/manifest.ts @@ -1,5 +1,6 @@ import semver from 'semver'; import { isKnownPermission } from '../protocol/envelope'; +import type { NotifEventType } from '../../../services/notificationPreferencesService'; /** * Parse + validate a plugin's trek-plugin.json (#plugins, M4). Kept deliberately @@ -38,9 +39,32 @@ export interface TripPageCapability { position?: number; } +/** Config for a plugin that implements the `notificationChannel` hook. */ +export interface NotificationChannelCapability { + /** Column name in the notification preferences matrix. Defaults to the plugin's name. */ + title?: string; + /** Narrow which events this channel carries. Defaults to every non-admin event. */ + events?: string[]; +} + +/** + * A button the plugin contributes to its own settings page ("Test connection", + * "Sync now"). Unlike the notification-channel hook, an action is USER-INITIATED — the + * acting user is whoever clicked — so `ctx.settings.get()` and trip reads work normally + * inside it, which is exactly what a "test my credentials" button needs. + */ +export interface ManifestAction { + key: string; + label: string; + hint?: string; + /** Render as destructive and ask for confirmation before running. */ + danger?: boolean; +} + export interface PluginCapabilities { widget?: WidgetCapability; tripPage?: TripPageCapability; + notificationChannel?: NotificationChannelCapability; /** Function names this plugin exposes to its dependents via ctx.plugins.call. */ provides?: string[]; /** Event names this plugin publishes to its dependents via ctx.events.emit. */ @@ -68,7 +92,15 @@ export interface PluginManifest { nativeModules: boolean; permissions: string[]; egress: string[]; + /** + * The plugin talks to a service whose hostname only the OPERATOR knows (a self-hosted + * Gotify, ntfy, Uptime Kuma…). Its manifest `egress` can never cover that, so an admin + * may add hosts post-install and the runtime unions them into the child's allow-list. + * Consent stays with the admin — an end user can never widen egress. + */ + operatorEgress: boolean; settings: ManifestSettingField[]; + actions: ManifestAction[]; capabilities: PluginCapabilities; /** Addon ids that must be enabled for this plugin to activate (format-only here). */ requiredAddons: string[]; @@ -129,6 +161,12 @@ export function parseManifest(raw: unknown): PluginManifest { if (badOutbound !== undefined) throw new ManifestError(`invalid http:outbound host "${badOutbound}"`); const egress = arr(m.egress).map(String); + if (m.operatorEgress !== undefined && typeof m.operatorEgress !== 'boolean') { + throw new ManifestError('operatorEgress must be a boolean'); + } + if (m.operatorEgress === true && !permissions.some((p) => p === 'http:outbound' || p.startsWith('http:outbound:'))) { + throw new ManifestError('operatorEgress requires an http:outbound permission'); + } if (permissions.some((p) => p === 'http:outbound' || p.startsWith('http:outbound:')) && egress.length === 0) { throw new ManifestError('http:outbound declared but egress[] is empty'); } @@ -152,7 +190,9 @@ export function parseManifest(raw: unknown): PluginManifest { nativeModules: false, permissions, egress, + operatorEgress: m.operatorEgress === true, settings: parseSettings(m.settings), + actions: parseActions(m.actions), capabilities: parseCapabilities(m.capabilities), requiredAddons: parseRequiredAddons(m.requiredAddons), pluginDependencies: parsePluginDependencies(m.pluginDependencies, id), @@ -231,6 +271,26 @@ function parseCapabilities(raw: unknown): PluginCapabilities { } if (Object.keys(page).length) out.tripPage = page; } + if (c.notificationChannel && typeof c.notificationChannel === 'object') { + const nc = c.notificationChannel as Record; + const channel: NotificationChannelCapability = {}; + const title = optStr(nc.title); + if (title) channel.title = title; + if (nc.events !== undefined) { + if (!Array.isArray(nc.events)) throw new ManifestError('capabilities.notificationChannel.events must be an array'); + const events: string[] = []; + for (const v of nc.events) { + if (typeof v !== 'string' || !(PLUGIN_CHANNEL_EVENTS as readonly string[]).includes(v)) { + throw new ManifestError( + `capabilities.notificationChannel.events: "${String(v)}" is not a plugin-deliverable event (${PLUGIN_CHANNEL_EVENTS.join(', ')})`, + ); + } + if (!events.includes(v)) events.push(v); + } + if (events.length) channel.events = events; + } + out.notificationChannel = channel; + } const provides = parseCapabilityNames(c.provides, 'provides'); if (provides.length) out.provides = provides; const emits = parseCapabilityNames(c.emits, 'emits'); @@ -238,6 +298,35 @@ function parseCapabilities(raw: unknown): PluginCapabilities { return out; } +/** + * Events a plugin notification channel may carry. Two exclusions, both deliberate: + * `version_available` is admin-scoped (it goes out over the admin's own global + * credentials, and a community plugin is never a recipient of one), and + * `synology_session_cleared` is in-app only. + * + * Spelled out rather than derived from the notification service, so the plugin + * installer carries no runtime dependency on it. The guard below is what keeps the + * two in step — same trick as `_eventKeyDriftGuard` in services/notifications.ts. + */ +export const PLUGIN_CHANNEL_EVENTS = [ + 'trip_invite', + 'booking_change', + 'trip_reminder', + 'todo_due', + 'vacay_invite', + 'collection_invite', + 'photos_shared', + 'collab_message', + 'packing_tagged', + 'plugin_notification', +] as const; + +// Compile-time guard: every id above must still be a real notification event. +// If one is renamed or dropped, this stops compiling. +type _ChannelEventsAreReal = (typeof PLUGIN_CHANNEL_EVENTS)[number] extends NotifEventType ? true : never; +const _channelEventDriftGuard: _ChannelEventsAreReal = true; +void _channelEventDriftGuard; + // Core planner tabs a trip-page plugin may replace while it is active. 'plan' is // deliberately NOT in this list — a trip always keeps its planner view, so a // plugin can take over bookings/transports/…, never the whole trip. @@ -255,12 +344,24 @@ function parseCapabilityNames(raw: unknown, field: string): string[] { return out; } +/** + * A settings field key. Constrained because the key is used as a JSON object key in + * the plugin's stored config: an unconstrained one (`__proto__`, `constructor`) resolves + * off Object.prototype on read, so a *required* field with such a name would look + * "configured" for every user who had configured nothing — enough, for a notification + * channel, to be dispatched to everyone without anyone entering credentials. + * (safeParse in plugins.service.ts now uses a null-prototype object too; this is the + * front door, that is the backstop.) + */ +const SETTING_KEY_RE = /^[a-zA-Z][a-zA-Z0-9_.-]{0,63}$/; +const RESERVED_SETTING_KEYS = new Set(['constructor', 'prototype', '__proto__']); + function parseSettings(raw: unknown): ManifestSettingField[] { if (!Array.isArray(raw)) return []; return raw .filter((s): s is Record => !!s && typeof s === 'object') .map((s): ManifestSettingField => ({ - key: String(s.key ?? ''), + key: assertSettingKey(String(s.key ?? '')), label: optStr(s.label), input_type: optStr(s.input_type) ?? 'text', placeholder: optStr(s.placeholder), @@ -268,12 +369,67 @@ function parseSettings(raw: unknown): ManifestSettingField[] { required: !!s.required, secret: !!s.secret, scope: s.scope === 'user' ? 'user' : 'instance', - options: Array.isArray(s.options) ? (s.options as Array<{ value: string; label: string }>) : undefined, + options: parseSettingOptions(s.options), oauth: s.oauth && typeof s.oauth === 'object' ? (s.oauth as { initPath?: string; callbackPath?: string }) : undefined, })) .filter((s) => s.key); } +/** + * A `select` field's options. The client renders `{value, label}`, so a bare string list — + * the obvious thing to write, and what a manifest was silently allowed to carry — rendered + * every option BLANK (value/label both undefined). Coerce the string form instead of + * failing on it, and reject anything that is neither. + */ +function parseSettingOptions(raw: unknown): Array<{ value: string; label: string }> | undefined { + if (raw === undefined) return undefined; + if (!Array.isArray(raw)) throw new ManifestError('settings option list must be an array'); + return raw.map((o) => { + if (typeof o === 'string' || typeof o === 'number') return { value: String(o), label: String(o) }; + if (o && typeof o === 'object') { + const { value, label } = o as { value?: unknown; label?: unknown }; + if (value === undefined || value === null || String(value) === '') { + throw new ManifestError('settings option must have a non-empty "value"'); + } + return { value: String(value), label: String(label ?? value) }; + } + throw new ManifestError(`invalid settings option ${JSON.stringify(o)} (expected a string or { value, label })`); + }); +} + +/** Settings-page action buttons. Bounded: the host renders the label itself. */ +function parseActions(raw: unknown): ManifestAction[] { + if (raw === undefined) return []; + if (!Array.isArray(raw)) throw new ManifestError('actions must be an array'); + if (raw.length > 8) throw new ManifestError('at most 8 actions'); + const out: ManifestAction[] = []; + for (const a of raw) { + if (!a || typeof a !== 'object') throw new ManifestError('each action must be an object'); + const { key, label, hint, danger } = a as Record; + const k = assertSettingKey(String(key ?? '')); + if (!k) throw new ManifestError('action must have a "key"'); + if (out.some((x) => x.key === k)) throw new ManifestError(`duplicate action "${k}"`); + out.push({ + key: k, + label: String(label ?? k).slice(0, 60), + hint: hint === undefined ? undefined : String(hint).slice(0, 200), + danger: danger === true, + }); + } + return out; +} + +function assertSettingKey(key: string): string { + // An empty key is dropped by the .filter below (a manifest may legitimately omit a + // field), but a PRESENT key that is malformed is a hard reject — silently ignoring it + // would leave the plugin expecting a setting the host will never store. + if (!key) return key; + if (!SETTING_KEY_RE.test(key) || RESERVED_SETTING_KEYS.has(key)) { + throw new ManifestError(`invalid settings key "${key}" (letters, digits, . _ - ; must start with a letter; 1–64 chars)`); + } + return key; +} + function str(v: unknown, name: string): string { if (typeof v !== 'string' || !v) throw new ManifestError(`missing/invalid "${name}"`); return v; diff --git a/server/src/nest/plugins/plugin-runtime.service.ts b/server/src/nest/plugins/plugin-runtime.service.ts index 0317c0f93..3915ac0be 100644 --- a/server/src/nest/plugins/plugin-runtime.service.ts +++ b/server/src/nest/plugins/plugin-runtime.service.ts @@ -4,6 +4,10 @@ import { db } from '../../db/database'; import { pluginsEnabled } from './kill-switch'; import { setPluginEventSink } from '../../plugin-event-sink'; import { setUserDeletedSink } from '../../plugin-user-lifecycle'; +import { setPluginChannelSource, pluginChannelId, type ChannelMessage, type ExternalChannel } from '../../services/notifications/channelRegistry'; +import { readUserSettingsDecrypted, hasRequiredUserSettings } from './plugins.service'; +import { PLUGIN_CHANNEL_EVENTS } from './install/manifest'; +import { stripEmoji } from './text-sanitize'; import { applyStagedPluginTrees, setStagedRestoreApplier } from './plugin-backup'; import { decrypt_api_key } from '../../services/apiKeyCrypto'; import { PluginSupervisor, type PluginRouteInfo } from './supervisor/plugin-supervisor'; @@ -26,6 +30,21 @@ import { parseDependencies, disabledRequiredAddons, resolveDependencyState, enab const HTTP_OUTBOUND = 'http:outbound:'; +// Mirrors HOST_RE in install/manifest.ts: an exact hostname or a `*.`-prefixed wildcard +// with a real multi-label suffix. Rejects a bare `*`, a whole-TLD wildcard, a scheme and +// any embedded space — the string is interpolated into the egress guard and the CSP. +const EGRESS_HOST_RE = /^(\*\.[a-z0-9-]+(\.[a-z0-9-]+)+|[a-z0-9-]+(\.[a-z0-9-]+)*)$/i; + +/** Hosts an admin added post-install for a plugin that declared `operatorEgress`. */ +function operatorEgressHosts(id: string): string[] { + try { + return (db.prepare('SELECT host FROM plugin_egress_hosts WHERE plugin_id = ? ORDER BY host').all(id) as Array<{ host: string }>) + .map((r) => r.host); + } catch { + return []; // table absent (a slimmed test app) — never block activation + } +} + /** * Remove `/` whether it is a real directory, a POSIX symlink or a * Windows junction — WITHOUT ever following a dev-link into (and deleting) the @@ -151,6 +170,10 @@ export class PluginRuntimeService implements OnModuleInit, OnModuleDestroy { // Forward core trip events to plugins that subscribed (events:subscribe). The // sink is name-only + fire-and-forget, so it can never block a core broadcast. setPluginEventSink((tripId, event, meta) => this.supervisor.deliverEvent(tripId, event, meta)); + // Expose plugin notification channels to the (plain, non-Nest) notification + // service. Pull-based: it calls this on every dispatch, so enabling or removing + // a plugin takes effect immediately with nothing to invalidate. + setPluginChannelSource(() => this.notificationChannels()); // Fan a deleted account out to plugins so they can erase their own per-user data. // Enqueued durably (survives restart), so nothing is lost if a plugin is offline. setUserDeletedSink((userId) => this.enqueueUserErasure(userId)); @@ -461,10 +484,60 @@ export class PluginRuntimeService implements OnModuleInit, OnModuleDestroy { // Mark it enabled (admin intent) so it reboots after restarts/crashes. db.prepare('UPDATE plugins SET granted_permissions = ?, enabled = 1 WHERE id = ?').run(JSON.stringify(declared), id); const config = decryptConfig(parseObject(row.config)); - const egress = declared.filter((p) => p.startsWith(HTTP_OUTBOUND)).map((p) => p.slice(HTTP_OUTBOUND.length)); + const manifestHosts = declared.filter((p) => p.startsWith(HTTP_OUTBOUND)).map((p) => p.slice(HTTP_OUTBOUND.length)); + // Union in the hosts the ADMIN added post-install. A plugin that talks to a + // self-hosted service can't name the operator's hostname in its manifest, so without + // this a community plugin (a Gotify channel, an ntfy channel) serves nobody. Only a + // plugin that DECLARED operatorEgress can have hosts, so the install-time consent + // still bounds what is possible — and it is always the admin, never an end user, + // who widens it. The egress list is spawn-time only, which is why changing it + // re-spawns the plugin (see setOperatorEgressHosts). + const egress = [...new Set([...manifestHosts, ...operatorEgressHosts(id)])]; await this.supervisor.activate(id, new Set(declared), config, egress); } + /** Hosts an admin added for this plugin (empty unless it declared `operatorEgress`). */ + operatorEgressHosts(id: string): string[] { + return operatorEgressHosts(id); + } + + /** Does this plugin's manifest declare that it needs operator-supplied hosts? */ + wantsOperatorEgress(id: string): boolean { + const row = db.prepare('SELECT operator_egress FROM plugins WHERE id = ?').get(id) as { operator_egress: number } | undefined; + return row?.operator_egress === 1; + } + + /** + * Replace the admin-supplied egress hosts for a plugin, then RE-SPAWN it so the child's + * guard picks the new list up — `installEgressGuard` runs once at init and a second + * `init` is deliberately refused, so there is no way to widen a live child's allow-list. + */ + async setOperatorEgressHosts(id: string, hosts: string[]): Promise { + if (!this.wantsOperatorEgress(id)) { + throw new ForbiddenResource(`plugin ${id} did not declare operatorEgress`); + } + const clean: string[] = []; + for (const raw of hosts) { + const host = String(raw ?? '').trim().toLowerCase().replace(/\.$/, ''); + if (!host) continue; + // Same shape the manifest enforces — this string is interpolated into the egress + // guard AND the iframe CSP connect-src, so a bare `*` or a scheme must never land. + if (host === '*' || !EGRESS_HOST_RE.test(host)) throw new ForbiddenResource(`invalid host "${raw}"`); + if (!clean.includes(host)) clean.push(host); + } + db.transaction(() => { + db.prepare('DELETE FROM plugin_egress_hosts WHERE plugin_id = ?').run(id); + const ins = db.prepare('INSERT OR IGNORE INTO plugin_egress_hosts (plugin_id, host) VALUES (?, ?)'); + for (const h of clean) ins.run(id, h); + })(); + // Re-spawn so a live child actually gets the new allow-list. + if (this.isActive(id)) { + await this.supervisor.disable(id); + await this.activate(id); + } + return clean; + } + async deactivate(id: string): Promise { await this.supervisor.disable(id); closePluginDataDb(id); @@ -653,9 +726,19 @@ export class PluginRuntimeService implements OnModuleInit, OnModuleDestroy { removePluginCodeEntry(pluginCodeDir(id)); db.prepare('DELETE FROM plugins WHERE id = ?').run(id); db.prepare('DELETE FROM plugin_settings_fields WHERE plugin_id = ?').run(id); + try { db.prepare('DELETE FROM plugin_actions WHERE plugin_id = ?').run(id); } catch { /* table absent */ } + // The admin's egress consent dies with the plugin. Unconditional: leaving it would + // silently grant a LATER plugin that reuses this id the hosts the admin approved for + // a different one. + try { db.prepare('DELETE FROM plugin_egress_hosts WHERE plugin_id = ?').run(id); } catch { /* table absent */ } // Scheduled tasks are operational (not user data), so they go unconditionally — // a scheduled callback for a plugin that no longer exists must never fire. db.prepare('DELETE FROM plugin_scheduled_tasks WHERE plugin_id = ?').run(id); + // If it was a notification channel, retire the channel too. Unconditional, for the + // same reason as the settings fields: these are TREK's config ABOUT the plugin, and + // leaving them means a later plugin that reuses this id silently inherits every + // user's opt-outs and the admin's enablement. + this.retireNotificationChannel(id); if (deleteData) { removePluginData(id); db.prepare('DELETE FROM plugin_error_log WHERE plugin_id = ?').run(id); @@ -770,6 +853,121 @@ export class PluginRuntimeService implements OnModuleInit, OnModuleDestroy { return semver.satisfies(target.version ?? '0.0.0', dep.version, { includePrerelease: true }); } + /** + * Drop every trace of a plugin's notification channel: the per-user event opt-outs + * and the id in the admin's `notification_channels` list. + */ + private retireNotificationChannel(id: string): void { + // Drop the users' per-event opt-outs for this channel. A plugin channel is never + // listed in the admin's `notification_channels` CSV (it is active by virtue of the + // plugin being enabled), so there is nothing to scrub there. + // + // Defensive, like the boot path above: an uninstall must still complete in a context + // without the notification tables (a slimmed test app that imports only the plugin + // module). + try { + db.prepare('DELETE FROM notification_channel_preferences WHERE channel = ?').run(pluginChannelId(id)); + } catch { /* no notifications schema here — nothing to retire */ } + } + + /** + * Every active, granted `notificationChannel` provider, as an ExternalChannel the + * notification service can dispatch to. Rebuilt on every read (the registry pulls + * through setPluginChannelSource), so a plugin that is disabled or uninstalled + * simply stops being a channel — no cache to invalidate. + * + * The label, the event set and the configured-check are all answered from the + * manifest and the DB, with no IPC: the child is only ever woken to actually send. + */ + notificationChannels(): ExternalChannel[] { + if (!pluginsEnabled()) return []; + return this.supervisor.providersOf('notificationChannel').map((id) => { + const row = db.prepare('SELECT name, capabilities FROM plugins WHERE id = ?').get(id) as + | { name: string; capabilities: string } + | undefined; + let cap: { title?: string; events?: string[] } = {}; + try { + cap = ((JSON.parse(row?.capabilities || '{}') as Record).notificationChannel ?? {}) as typeof cap; + } catch { /* a malformed capabilities blob just means "no overrides" */ } + + const allowed = new Set(cap.events?.length ? cap.events : PLUGIN_CHANNEL_EVENTS); + + return { + id: pluginChannelId(id), + source: 'plugin' as const, + // A plugin-supplied display string, bounded and emoji-stripped like every other + // one the host renders (cf. the calendar/photo controllers) — it becomes a column + // header in the notification preferences matrix. + label: stripEmoji(String(cap.title || row?.name || id)).slice(0, 40) || id, + // Where the user actually configures this channel. Settings is ONE page with a + // tab, not a route per plugin — a `/settings/plugins/` path would 404. + settingsPath: '/settings?tab=plugins', + // Admin-scoped events never reach a plugin channel — PLUGIN_CHANNEL_EVENTS + // excludes them, and a manifest can only narrow that set, never widen it. + supportsEvent: (event: string) => allowed.has(event), + isConfiguredFor: (userId: number) => hasRequiredUserSettings(id, userId), + sendToUser: (userId: number, msg: ChannelMessage) => + this.invokeHook( + id, + 'notificationChannel', + 'send', + [{ event: msg.event, title: msg.title, body: msg.body, url: msg.url, tripName: msg.tripName }, readUserSettingsDecrypted(id, userId)], + // No acting user: a notification is host-initiated for an arbitrary + // recipient, so the hook gets the recipient's config as an argument + // rather than the right to read anything AS them. + undefined, + 8000, + ), + test: async (userId: number) => { + try { + await this.invokeHook(id, 'notificationChannel', 'test', [readUserSettingsDecrypted(id, userId)], undefined, 8000); + return { success: true }; + } catch (e) { + return { success: false, error: e instanceof Error ? e.message : String(e) }; + } + }, + }; + }); + } + + /** The settings-page action buttons a plugin declared (descriptors, from the DB). */ + actionsOf(id: string): Array<{ key: string; label: string; hint?: string; danger: boolean }> { + try { + return ( + db.prepare('SELECT action_key, label, hint, danger FROM plugin_actions WHERE plugin_id = ? ORDER BY sort_order').all(id) as Array<{ + action_key: string; label: string; hint: string | null; danger: number; + }> + ).map((r) => ({ key: r.action_key, label: r.label, hint: r.hint ?? undefined, danger: r.danger === 1 })); + } catch { + return []; // table absent (a slimmed test app) + } + } + + /** + * Run a settings-page action for the user who clicked it. The acting user is bound + * host-side (never named by the plugin), so the action reads THAT user's settings and + * any trip read it makes is membership-checked against them. + */ + async invokeAction(id: string, key: string, actingUserId: number): Promise<{ ok: boolean; message?: string }> { + if (!this.actionsOf(id).some((a) => a.key === key)) { + throw new ForbiddenResource(`plugin ${id} did not declare action "${key}"`); + } + const cap = (v: unknown) => stripEmoji(String(v)).slice(0, 200); + try { + const raw = (await this.supervisor.invoke(id, 'invoke.action', { key }, { actingUserId, timeoutMs: 15_000 })) as + | { ok?: unknown; message?: unknown } + | undefined; + // The message is plugin-supplied and rendered to the user — bound it like every + // other plugin string the host displays. + return { ok: raw?.ok !== false, message: raw?.message === undefined ? undefined : cap(raw.message) }; + } catch (e) { + // A plugin that throws is reporting a FAILED action, not a server fault — that is + // the documented contract ("throwing == { ok: false }"), and it is the normal path + // for "your credentials don't work". Surface the reason to the user. + return { ok: false, message: cap(e instanceof Error ? e.message : 'Action failed') }; + } + } + /** A plugin's declared `capabilities.provides`/`capabilities.emits` (from the DB). */ private capabilityList(id: string, field: 'provides' | 'emits'): string[] { const row = db.prepare('SELECT capabilities FROM plugins WHERE id = ?').get(id) as { capabilities: string } | undefined; diff --git a/server/src/nest/plugins/plugin-user-settings.controller.ts b/server/src/nest/plugins/plugin-user-settings.controller.ts index 9d2d1148f..408811e22 100644 --- a/server/src/nest/plugins/plugin-user-settings.controller.ts +++ b/server/src/nest/plugins/plugin-user-settings.controller.ts @@ -1,8 +1,9 @@ -import { Body, Controller, Get, Param, Post, Req, UseGuards } from '@nestjs/common'; +import { Body, Controller, Get, HttpCode, HttpException, Param, Post, Req, UseGuards } from '@nestjs/common'; import type { Request } from 'express'; import { JwtAuthGuard } from '../auth/jwt-auth.guard'; import { pluginsEnabled } from './kill-switch'; import { PluginsService } from './plugins.service'; +import { PluginRuntimeService } from './plugin-runtime.service'; import { db } from '../../db/database'; /** @@ -19,7 +20,10 @@ import { db } from '../../db/database'; @Controller('api/plugin-settings') @UseGuards(JwtAuthGuard) export class PluginUserSettingsController { - constructor(private readonly plugins: PluginsService) {} + constructor( + private readonly plugins: PluginsService, + private readonly runtime: PluginRuntimeService, + ) {} private activeWithUserFields(id: string): boolean { const row = db.prepare("SELECT 1 FROM plugins WHERE id = ? AND status = 'active'").get(id); @@ -27,10 +31,43 @@ export class PluginUserSettingsController { } @Get(':id') - get(@Param('id') id: string, @Req() req: Request & { user?: { id: number } }): { fields: unknown[]; config: Record } { + get(@Param('id') id: string, @Req() req: Request & { user?: { id: number } }): { + fields: unknown[]; + config: Record; + actions: Array<{ key: string; label: string; hint?: string; danger: boolean }>; + } { const userId = req.user?.id; - if (!pluginsEnabled() || userId == null || !this.activeWithUserFields(id)) return { fields: [], config: {} }; - return { fields: this.plugins.userSettingsFields(id), config: this.plugins.getUserConfig(id, userId) }; + if (!pluginsEnabled() || userId == null || !this.activeWithUserFields(id)) return { fields: [], config: {}, actions: [] }; + return { + fields: this.plugins.userSettingsFields(id), + config: this.plugins.getUserConfig(id, userId), + actions: this.runtime.actionsOf(id), + }; + } + + /** + * Run one of the plugin's declared settings-page actions ("Test connection"). + * USER-INITIATED: the acting user is the caller, bound host-side — so the action reads + * the CALLER's own settings and any trip read it makes is checked against them. It can + * never act as anyone else, and a key the plugin didn't declare is refused. + */ + @Post(':id/actions/:key') + @HttpCode(200) + async runAction( + @Param('id') id: string, + @Param('key') key: string, + @Req() req: Request & { user?: { id: number } }, + ): Promise<{ ok: boolean; message?: string }> { + const userId = req.user?.id; + if (!pluginsEnabled() || userId == null || !this.activeWithUserFields(id)) { + throw new HttpException({ error: 'Plugin is not active' }, 404); + } + try { + return await this.runtime.invokeAction(id, key, userId); + } catch (e) { + // A failing action is a RESULT, not a server error — show the user why. + return { ok: false, message: (e instanceof Error ? e.message : 'Action failed').slice(0, 200) }; + } } @Post(':id') diff --git a/server/src/nest/plugins/plugins.controller.ts b/server/src/nest/plugins/plugins.controller.ts index e5308113a..8d7e05dc9 100644 --- a/server/src/nest/plugins/plugins.controller.ts +++ b/server/src/nest/plugins/plugins.controller.ts @@ -103,6 +103,28 @@ export class PluginsController { return { config: this.plugins.updateInstanceConfig(id, body || {}) }; } + /** + * Operator-supplied egress hosts. A plugin that talks to a SELF-HOSTED service can't + * name the operator's hostname in its manifest, so an admin adds it here — and the + * runtime re-spawns the plugin with the widened allow-list. Admin-only (this controller + * is admin-guarded): an end user can never widen a plugin's egress. + */ + @Get(':id/egress-hosts') + egressHosts(@Param('id') id: string) { + return { supported: this.runtime.wantsOperatorEgress(id), hosts: this.runtime.operatorEgressHosts(id) }; + } + + @Put(':id/egress-hosts') + async setEgressHosts(@Param('id') id: string, @Body() body: { hosts?: unknown } = {}) { + if (!pluginsEnabled()) throw new HttpException({ error: 'Plugins are disabled by server configuration' }, 503); + const hosts = Array.isArray(body.hosts) ? body.hosts.map(String) : []; + try { + return { hosts: await this.runtime.setOperatorEgressHosts(id, hosts) }; + } catch (e) { + throw new HttpException({ error: e instanceof Error ? e.message : 'Invalid hosts' }, 400); + } + } + @Post(':id/activate') @HttpCode(200) async activate(@Param('id') id: string, @Body() body: { consent?: boolean } = {}) { diff --git a/server/src/nest/plugins/plugins.service.ts b/server/src/nest/plugins/plugins.service.ts index 6d465a2b6..4da84a2d0 100644 --- a/server/src/nest/plugins/plugins.service.ts +++ b/server/src/nest/plugins/plugins.service.ts @@ -37,6 +37,15 @@ interface PluginRawRow { dependencies: string | null; } +/** Hosts an admin has added for a plugin (0 unless it declared operatorEgress). */ +function egressHostCount(id: string): number { + try { + return (db.prepare('SELECT COUNT(*) AS n FROM plugin_egress_hosts WHERE plugin_id = ?').get(id) as { n: number }).n; + } catch { + return 0; // table absent (a slimmed test app) + } +} + export interface PluginListItem { id: string; name: string; @@ -53,6 +62,10 @@ export interface PluginListItem { permissions: string; /** Declared capabilities (JSON string) — e.g. widget slot. */ capabilities: string; + /** The plugin declared it needs OPERATOR-supplied egress hosts (a self-hosted target). */ + operatorEgress: boolean; + /** How many hosts an admin has actually added — so the card can nudge when it's 0. */ + egressHostCount: number; /** Declared dependencies (parsed) — required addons + plugin deps. */ dependencies: PluginDependencies; /** Whether this plugin can currently activate, and why not if it can't. */ @@ -67,7 +80,7 @@ export class PluginsService { const rows = db .prepare( `SELECT id, name, description, type, icon, version, status, enabled, last_error, reviewed_at, source_repo, - permissions, capabilities, dependencies + permissions, capabilities, dependencies, operator_egress FROM plugins ORDER BY sort_order, name`, ) @@ -84,9 +97,11 @@ export class PluginsService { : state.missing.length || state.versionMismatch.length ? 'missingPlugin' : 'ok'; - const { dependencies: _raw, ...rest } = r; + const { dependencies: _raw, operator_egress: _oe, ...rest } = r as PluginRawRow & { operator_egress?: number }; return { ...rest, + operatorEgress: _oe === 1, + egressHostCount: egressHostCount(r.id), dependencies: deps, dependencyStatus, dependencyIssues: { disabledAddons, missing: state.missing, versionMismatch: state.versionMismatch }, @@ -244,12 +259,28 @@ export class PluginsService { } } +/** + * Parse a stored plugin config blob into a NULL-PROTOTYPE object. + * + * The prototype matters: a settings key of `__proto__` or `constructor` would otherwise + * resolve off Object.prototype on read, so `config[key]` comes back truthy for a user who + * has configured nothing — and a *required* field with such a name would report as + * configured for everyone. The manifest now rejects those keys at install + * (SETTING_KEY_RE); this makes it impossible regardless, including for a plugin that was + * installed before that check existed. + */ function safeParse(json: string): Record { + const empty = () => Object.create(null) as Record; try { const v = JSON.parse(json || '{}'); - return v && typeof v === 'object' && !Array.isArray(v) ? (v as Record) : {}; + if (!v || typeof v !== 'object' || Array.isArray(v)) return empty(); + const out = empty(); + // JSON.parse creates `__proto__` as an OWN property (it never invokes the setter), so + // copy own keys across onto the null-prototype object rather than trusting the parse. + for (const [k, val] of Object.entries(v as Record)) out[k] = val; + return out; } catch { - return {}; + return empty(); } } function safeArray(json: string): unknown[] | undefined { @@ -275,6 +306,48 @@ export function readUserSettingDecrypted(pluginId: string, userId: number, key: if (v == null) return undefined; return isSecret ? decrypt_api_key(v) : v; } +/** Host-only: ALL of a plugin's per-user settings for one user, decrypted. + * This is how a notification-channel hook reaches the recipient's credentials — + * that dispatch is host-initiated with no acting user, so `ctx.settings.get()` + * (which resolves against the acting user) would return undefined there. Never + * send this to a client. */ +export function readUserSettingsDecrypted(pluginId: string, userId: number): Record { + const fields = db + .prepare("SELECT field_key, secret FROM plugin_settings_fields WHERE plugin_id = ? AND scope = 'user'") + .all(pluginId) as Array<{ field_key: string; secret: number }>; + const row = db.prepare('SELECT config FROM plugin_user_config WHERE plugin_id = ? AND user_id = ?').get(pluginId, userId) as + | { config: string } + | undefined; + const stored = safeParse(row?.config ?? '{}'); + // Null-prototype for the same reason as safeParse: never let a field key write through + // to Object.prototype on the way out to the plugin. + const out: Record = Object.create(null); + for (const f of fields) { + const v = stored[f.field_key]; + if (v == null) continue; + out[f.field_key] = f.secret === 1 ? decrypt_api_key(v) : v; + } + return out; +} + +/** Has this user filled in every `required`, `scope:'user'` field the plugin declares? + * A plugin with no required user fields is configured for everyone (an instance-wide + * channel, e.g. a shared workspace webhook). */ +export function hasRequiredUserSettings(pluginId: string, userId: number): boolean { + const required = db + .prepare("SELECT field_key FROM plugin_settings_fields WHERE plugin_id = ? AND scope = 'user' AND required = 1") + .all(pluginId) as Array<{ field_key: string }>; + if (required.length === 0) return true; + const row = db.prepare('SELECT config FROM plugin_user_config WHERE plugin_id = ? AND user_id = ?').get(pluginId, userId) as + | { config: string } + | undefined; + const stored = safeParse(row?.config ?? '{}'); + return required.every(f => { + const v = stored[f.field_key]; + return v != null && String(v) !== ''; + }); +} + function maskSecrets(config: Record, secretKeys: Set): Record { const out: Record = {}; for (const [k, v] of Object.entries(config)) { diff --git a/server/src/nest/plugins/protocol/envelope.ts b/server/src/nest/plugins/protocol/envelope.ts index 07b74ad13..2b3e60ebb 100644 --- a/server/src/nest/plugins/protocol/envelope.ts +++ b/server/src/nest/plugins/protocol/envelope.ts @@ -328,6 +328,7 @@ export const KNOWN_PERMISSIONS = [ 'hook:atlas-layer-provider', 'hook:journal-entry-provider', 'hook:trip-card-provider', + 'hook:notification-channel', // Data-subject-rights hook: the host calls the plugin's deleteUserData / // exportUserData when a TREK account is erased or its data is exported. Userless // (the plugin only receives a userId and acts on its OWN db), so it grants no diff --git a/server/src/nest/plugins/registry/registry.service.ts b/server/src/nest/plugins/registry/registry.service.ts index ff9b50956..bca79c7b2 100644 --- a/server/src/nest/plugins/registry/registry.service.ts +++ b/server/src/nest/plugins/registry/registry.service.ts @@ -74,6 +74,13 @@ interface Registry { export interface ManifestPreview { permissions: string[]; egress: string[]; + /** + * The plugin talks to a service only the OPERATOR can name (a self-hosted Gotify/ntfy), + * so its `egress` can't be the whole story — an admin adds the real hosts after install. + * Surfaced pre-install so the reviewer knows the network reach is not fully described by + * the hosts listed above. + */ + operatorEgress: boolean; settings: Array<{ key: string; label: string; inputType: string; scope: string; required: boolean }>; license: string | null; icon: string | null; @@ -390,6 +397,7 @@ function hostCompatible(v: RegistryVersion, host: string): boolean { */ function previewManifest(raw: unknown): ManifestPreview { const m = (raw && typeof raw === 'object' ? raw : {}) as Record; + const operatorEgress = m.operatorEgress === true; const strings = (v: unknown): string[] => Array.isArray(v) ? v.filter((x): x is string => typeof x === 'string') : []; const settings = Array.isArray(m.settings) @@ -413,6 +421,7 @@ function previewManifest(raw: unknown): ManifestPreview { return { permissions: strings(m.permissions), egress: strings(m.egress), + operatorEgress, settings, license: typeof m.license === 'string' ? m.license : null, icon: typeof m.icon === 'string' ? m.icon : null, diff --git a/server/src/nest/plugins/runtime/plugin-host-entry.ts b/server/src/nest/plugins/runtime/plugin-host-entry.ts index a60b82039..d44f3e53d 100644 --- a/server/src/nest/plugins/runtime/plugin-host-entry.ts +++ b/server/src/nest/plugins/runtime/plugin-host-entry.ts @@ -177,6 +177,16 @@ async function handleInvoke(req: { id: string; method: string; params: Record unknown) | undefined> | undefined; + const fn = actions?.[key]; + if (typeof fn !== 'function') throw new Error(`no action ${key}`); + const result = await fn(invCtx); + respond(true, result ?? { ok: true }); } else if (req.method === 'invoke.event') { // A core event fired for a trip. Run every matching subscription. invCtx carries // NO user (delivered like a job), so trip reads are refused — the handler reacts diff --git a/server/src/nest/plugins/runtime/plugin-sdk.ts b/server/src/nest/plugins/runtime/plugin-sdk.ts index 23800396f..73af07acc 100644 --- a/server/src/nest/plugins/runtime/plugin-sdk.ts +++ b/server/src/nest/plugins/runtime/plugin-sdk.ts @@ -347,6 +347,46 @@ export interface PhotoProvider { search(query: string, opts: { page: number; limit: number }, ctx: PluginContext): Promise<{ photos: Photo[]; total: number; hasMore: boolean }>; getById(id: string, ctx: PluginContext): Promise; } +/** + * A notification, already rendered by the host into the recipient's language. + * A channel plugin never touches i18n — it only delivers. + */ +export interface NotificationMessage { + /** The TREK event that produced this, e.g. `trip_invite`. */ + event: string; + title: string; + body: string; + /** Absolute deep link back into TREK, when the event has one. */ + url?: string; + tripName?: string; +} + +/** + * Deliver TREK notifications over a channel TREK doesn't ship (Gotify, Pushover, …). + * Needs `hook:notification-channel`. + * + * Unlike every other hook, this one is HOST-initiated for an arbitrary recipient, so + * it runs WITHOUT an acting user: `ctx.settings.get()` returns undefined here and trip + * reads are refused. The recipient's own `scope:'user'` settings are decrypted by the + * host and handed to you as `config` instead — that is the only way to reach them, and + * it is why a channel plugin cannot enumerate the recipient's trips. + */ +export interface NotificationChannel { + send(msg: NotificationMessage, config: Record, ctx: PluginContext): Promise; + /** Backs the "Send test" button in the user's notification settings. */ + test?(config: Record, ctx: PluginContext): Promise; +} + +/** + * Result of a settings-page action. `message` is shown to the user beside the button + * (bounded + emoji-stripped host-side); throwing is equivalent to `{ ok: false }` with + * the error message. + */ +export interface PluginActionResult { + ok?: boolean; + message?: string; +} + export interface CalendarEvent { id: string; title: string; start: string; end: string; allDay: boolean; } export interface CalendarSource { getName(ctx: PluginContext): string; @@ -504,6 +544,16 @@ export interface PluginDefinition { /** GDPR portability: return the data you hold about a user (own db only), as a * JSON-serialisable value the host aggregates. Userless. Needs `hook:user-data`. */ exportUserData?(input: { userId: number }, ctx: PluginContext): Promise | unknown; + /** + * Buttons on the plugin's own settings page ("Test connection", "Sync now"). The key + * must match an entry in the manifest's `actions`. + * + * USER-INITIATED, so unlike the notificationChannel hook there IS an acting user — the + * person who clicked. `ctx.settings.get()` returns THEIR value and trip reads are + * membership-checked against them, which is what makes a "test my credentials" button + * possible at all. + */ + actions?: Record Promise | PluginActionResult | void>; events?: PluginEventSubscription[]; hooks?: { photoProvider?: PhotoProvider; @@ -516,6 +566,7 @@ export interface PluginDefinition { atlasLayerProvider?: AtlasLayerProvider; journalEntryProvider?: JournalEntryProvider; tripCardProvider?: TripCardProvider; + notificationChannel?: NotificationChannel; }; /** Functions exposed to dependents (names must match manifest capabilities.provides). */ exports?: Record; diff --git a/server/src/nest/plugins/supervisor/plugin-supervisor.ts b/server/src/nest/plugins/supervisor/plugin-supervisor.ts index ecc87642f..352765d9e 100644 --- a/server/src/nest/plugins/supervisor/plugin-supervisor.ts +++ b/server/src/nest/plugins/supervisor/plugin-supervisor.ts @@ -105,6 +105,7 @@ const HOOK_PERMISSION: Readonly> = { atlasLayerProvider: 'hook:atlas-layer-provider', journalEntryProvider: 'hook:journal-entry-provider', tripCardProvider: 'hook:trip-card-provider', + notificationChannel: 'hook:notification-channel', }; export class PluginSupervisor { diff --git a/server/src/services/notificationPreferencesService.ts b/server/src/services/notificationPreferencesService.ts index 0d538bae4..9f9d2d0a0 100644 --- a/server/src/services/notificationPreferencesService.ts +++ b/server/src/services/notificationPreferencesService.ts @@ -1,10 +1,23 @@ import type Database from 'better-sqlite3'; import { db } from '../db/database'; -import { decrypt_api_key } from './apiKeyCrypto'; +import { isSmtpConfigured } from './notifications'; +import { listChannels, type ExternalChannel } from './notifications/channelRegistry'; +// Side-effect import: populates the registry with email/webhook/ntfy. Safe from +// here — builtins only reaches ../notifications, which imports this module's +// types with `import type`, so there is no runtime cycle. +import './notifications/builtins'; // ── Types ────────────────────────────────────────────────────────────────── -export type NotifChannel = 'email' | 'webhook' | 'inapp' | 'ntfy'; +/** + * A channel id. Open by design: the built-ins below plus `plugin:` for any + * plugin implementing the `notificationChannel` hook. The DB column is a bare + * TEXT with no CHECK constraint and the Zod contract is already a string record, + * so the set was only ever closed in TypeScript. + */ +export type NotifChannel = string; + +export const INAPP_CHANNEL = 'inapp'; export type NotifEventType = | 'trip_invite' @@ -20,29 +33,51 @@ export type NotifEventType = | 'synology_session_cleared' | 'plugin_notification'; -export interface AvailableChannels { - email: boolean; - webhook: boolean; - inapp: boolean; - ntfy: boolean; +/** Every event, in the order the preferences UI lists them. */ +export const ALL_EVENT_TYPES: NotifEventType[] = [ + 'trip_invite', + 'booking_change', + 'trip_reminder', + 'todo_due', + 'vacay_invite', + 'collection_invite', + 'photos_shared', + 'collab_message', + 'packing_tagged', + 'version_available', + 'synology_session_cleared', + 'plugin_notification', +]; + +/** One channel column in the preferences matrix. */ +export interface ChannelDescriptor { + id: string; + source: 'builtin' | 'plugin'; + /** Built-ins: an i18n key. */ + labelKey?: string; + /** Plugin channels: a literal display name. */ + label?: string; + /** Where the user sets their credentials for this channel, if anywhere. */ + settingsPath?: string; + /** The admin has enabled this channel (in-app is always on). */ + active: boolean; + /** This user has credentials for it. */ + configured: boolean; } -// Which channels are implemented for each event type. -// Only implemented combos show toggles in the user preferences UI. -const IMPLEMENTED_COMBOS: Record = { - trip_invite: ['inapp', 'email', 'webhook', 'ntfy'], - booking_change: ['inapp', 'email', 'webhook', 'ntfy'], - trip_reminder: ['inapp', 'email', 'webhook', 'ntfy'], - todo_due: ['inapp', 'email', 'webhook', 'ntfy'], - vacay_invite: ['inapp', 'email', 'webhook', 'ntfy'], - collection_invite: ['inapp', 'email', 'webhook', 'ntfy'], - photos_shared: ['inapp', 'email', 'webhook', 'ntfy'], - collab_message: ['inapp', 'email', 'webhook', 'ntfy'], - packing_tagged: ['inapp', 'email', 'webhook', 'ntfy'], - version_available: ['inapp', 'email', 'webhook', 'ntfy'], - synology_session_cleared: ['inapp'], - plugin_notification: ['inapp', 'email', 'webhook', 'ntfy'], -}; +/** + * Channels implemented for an event. In-app takes everything; external channels + * decide for themselves (today: everything except `synology_session_cleared`). + */ +export function combosFor(event: NotifEventType): NotifChannel[] { + return [INAPP_CHANNEL, ...listChannels().filter(c => c.supportsEvent(event)).map(c => c.id)]; +} + +function allCombos(): Record { + const out: Record = {}; + for (const event of ALL_EVENT_TYPES) out[event] = combosFor(event); + return out; +} /** Events that target admins only (shown in admin panel, not in user settings). */ export const ADMIN_SCOPED_EVENTS = new Set(['version_available']); @@ -56,24 +91,26 @@ function getAppSetting(key: string): string | null { // ── Active channels (admin-configured) ──────────────────────────────────── /** - * Returns which channels the admin has enabled (email and/or webhook). + * Which channels the admin has enabled, as ids. * Reads `notification_channels` (plural) with fallback to `notification_channel` (singular). - * In-app is always considered active at the service level. + * + * BUILT-INS ONLY. A plugin channel is NOT gated on this list: a built-in always exists in + * the code and so needs an explicit switch, but a plugin channel only exists because an + * admin installed and enabled that plugin — that IS the opt-in. Requiring a second one + * meant a plugin channel could never be turned on at all (nothing writes a `plugin:` id + * into this CSV, and the admin toggle rebuilds it from the three built-in booleans, which + * would silently drop any that were). */ export function getActiveChannels(): NotifChannel[] { const raw = getAppSetting('notification_channels') || getAppSetting('notification_channel') || 'none'; if (raw === 'none') return []; - return raw.split(',').map(c => c.trim()).filter((c): c is NotifChannel => c === 'email' || c === 'webhook' || c === 'ntfy'); + const builtins = new Set(listChannels().filter(c => c.source === 'builtin').map(c => c.id)); + return raw.split(',').map(c => c.trim()).filter(c => builtins.has(c)); } -/** - * Returns which channels are configured (have valid credentials/URLs set). - * In-app is always available. Email/webhook depend on configuration. - */ -export function getAvailableChannels(): AvailableChannels { - const hasSmtp = !!(process.env.SMTP_HOST || getAppSetting('smtp_host')); - const activeChannels = getActiveChannels(); - return { email: hasSmtp, webhook: activeChannels.includes('webhook'), ntfy: activeChannels.includes('ntfy'), inapp: true }; +/** Is this channel switched on? Plugin channels are on by virtue of being live. */ +export function isChannelActive(channel: ExternalChannel): boolean { + return channel.source === 'plugin' || getActiveChannels().includes(channel.id); } // ── Per-user preference checks ───────────────────────────────────────────── @@ -93,12 +130,71 @@ export function isEnabledForEvent(userId: number, eventType: NotifEventType, cha export interface PreferencesMatrix { preferences: Partial>>>; - available_channels: AvailableChannels; + /** The columns to render, in order. Replaces the old fixed-shape available_channels. */ + channels: ChannelDescriptor[]; event_types: NotifEventType[]; - implemented_combos: Record; + implemented_combos: Record; defaults?: { ntfyServer: string | null }; } +/** The in-app pseudo-channel — always active, never configurable. */ +function inAppDescriptor(): ChannelDescriptor { + return { + id: INAPP_CHANNEL, + source: 'builtin', + labelKey: 'settings.notificationPreferences.inapp', + active: true, + configured: true, + }; +} + +/** + * The channel columns for a scope. + * scope='user' — a column per channel the admin turned on in `notification_channels`. + * scope='admin' — a column per channel that has admin-global credentials. + */ +function describeChannels(userId: number, scope: 'user' | 'admin'): ChannelDescriptor[] { + const out: ChannelDescriptor[] = [inAppDescriptor()]; + + if (scope === 'admin') { + // Admin-scoped events go out over the admin's own global credentials, which + // are independent of the per-user `notification_channels` toggle. + const hasSmtp = isSmtpConfigured(); + const hasAdminWebhook = !!getAppSetting('admin_webhook_url'); + const hasAdminNtfy = !!getAppSetting('admin_ntfy_topic'); + const adminActive: Record = { email: hasSmtp, webhook: hasAdminWebhook, ntfy: hasAdminNtfy }; + for (const channel of listChannels()) { + // Plugin channels are user-scoped only — they never carry admin-global events. + if (channel.source !== 'builtin') continue; + const active = adminActive[channel.id] ?? false; + out.push({ + id: channel.id, + source: channel.source, + labelKey: channel.labelKey, + label: channel.label, + active, + configured: active, + }); + } + return out; + } + + for (const channel of listChannels()) { + out.push({ + id: channel.id, + source: channel.source, + labelKey: channel.labelKey, + label: channel.label, + settingsPath: channel.settingsPath, + // A live plugin channel is always a column. `configured` tells the user whether + // they still need to enter credentials — it does not hide the channel from them. + active: isChannelActive(channel), + configured: channel.isConfiguredFor(userId), + }); + } + return out; +} + /** * Returns the preferences matrix for a user. * scope='user' — excludes admin-scoped events (for user settings page) @@ -116,16 +212,16 @@ export function getPreferencesMatrix(userId: number, userRole: string, scope: 'u stored[row.event_type]![row.channel] = row.enabled === 1; } + const implemented_combos = allCombos(); + // Build the full matrix with defaults (true when no row exists) const preferences: Partial>>> = {}; - const allEvents = Object.keys(IMPLEMENTED_COMBOS) as NotifEventType[]; - for (const eventType of allEvents) { - const channels = IMPLEMENTED_COMBOS[eventType]; + for (const eventType of ALL_EVENT_TYPES) { preferences[eventType] = {}; - for (const channel of channels) { - // Admin-scoped events use global settings for email/webhook/ntfy - if (scope === 'admin' && ADMIN_SCOPED_EVENTS.has(eventType) && (channel === 'email' || channel === 'webhook' || channel === 'ntfy')) { + for (const channel of implemented_combos[eventType]) { + // 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] = getAdminGlobalPref(eventType, channel); } else { preferences[eventType]![channel] = stored[eventType]?.[channel] ?? true; @@ -135,50 +231,43 @@ export function getPreferencesMatrix(userId: number, userRole: string, scope: 'u // Filter event types by scope const event_types = scope === 'admin' - ? allEvents.filter(e => ADMIN_SCOPED_EVENTS.has(e)) - : allEvents.filter(e => !ADMIN_SCOPED_EVENTS.has(e)); - - // Available channels depend on scope - let available_channels: AvailableChannels; - if (scope === 'admin') { - const hasSmtp = !!(process.env.SMTP_HOST || getAppSetting('smtp_host')); - const hasAdminWebhook = !!(getAppSetting('admin_webhook_url')); - const hasAdminNtfy = !!(getAppSetting('admin_ntfy_topic')); - available_channels = { email: hasSmtp, webhook: hasAdminWebhook, ntfy: hasAdminNtfy, inapp: true }; - } else { - const activeChannels = getActiveChannels(); - available_channels = { - email: activeChannels.includes('email'), - webhook: activeChannels.includes('webhook'), - ntfy: activeChannels.includes('ntfy'), - inapp: true, - }; - } + ? ALL_EVENT_TYPES.filter(e => ADMIN_SCOPED_EVENTS.has(e)) + : ALL_EVENT_TYPES.filter(e => !ADMIN_SCOPED_EVENTS.has(e)); return { preferences, - available_channels, + channels: describeChannels(userId, scope), event_types, - implemented_combos: IMPLEMENTED_COMBOS, + implemented_combos, ...(scope === 'user' && { defaults: { ntfyServer: getAppSetting('admin_ntfy_server') || null } }), }; } // ── Admin global preferences (stored in app_settings) ───────────────────── -const ADMIN_GLOBAL_CHANNELS: NotifChannel[] = ['email', 'webhook', 'ntfy']; +/** + * Channels whose admin-scoped preference is global (app_settings) rather than + * per-user. Built-ins only: plugin channels are user-scoped and never carry + * admin-scoped events. + */ +const ADMIN_GLOBAL_CHANNELS = ['email', 'webhook', 'ntfy'] as const; +export type AdminGlobalChannel = (typeof ADMIN_GLOBAL_CHANNELS)[number]; + +export function isAdminGlobalChannel(channel: string): channel is AdminGlobalChannel { + return (ADMIN_GLOBAL_CHANNELS as readonly string[]).includes(channel); +} /** * Returns the global admin preference for an event+channel. * Stored in app_settings as `admin_notif_pref_{event}_{channel}`. * Defaults to true (enabled) when no row exists. */ -export function getAdminGlobalPref(event: NotifEventType, channel: 'email' | 'webhook' | 'ntfy'): boolean { +export function getAdminGlobalPref(event: NotifEventType, channel: AdminGlobalChannel): boolean { const val = getAppSetting(`admin_notif_pref_${event}_${channel}`); return val !== '0'; } -function setAdminGlobalPref(event: NotifEventType, channel: 'email' | 'webhook' | 'ntfy', enabled: boolean): void { +function setAdminGlobalPref(event: NotifEventType, channel: AdminGlobalChannel, enabled: boolean): void { db.prepare('INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)').run( `admin_notif_pref_${event}_${channel}`, enabled ? '1' : '0' @@ -248,7 +337,7 @@ export function setAdminPreferences( for (const [eventType, channels] of Object.entries(prefs)) { if (!channels) continue; for (const [channel, enabled] of Object.entries(channels)) { - if (ADMIN_GLOBAL_CHANNELS.includes(channel as NotifChannel)) { + if (isAdminGlobalChannel(channel)) { if (!globalPrefs[eventType]) globalPrefs[eventType] = {}; globalPrefs[eventType]![channel] = enabled; } else { @@ -262,7 +351,8 @@ export function setAdminPreferences( for (const [eventType, channels] of Object.entries(globalPrefs)) { if (!channels) continue; for (const [channel, enabled] of Object.entries(channels)) { - setAdminGlobalPref(eventType as NotifEventType, channel as 'email' | 'webhook' | 'ntfy', enabled); + if (!isAdminGlobalChannel(channel)) continue; + setAdminGlobalPref(eventType as NotifEventType, channel, enabled); } } @@ -271,10 +361,10 @@ export function setAdminPreferences( } // ── SMTP availability helper (for authService) ───────────────────────────── +// Lives in ./notifications now (the module that owns SMTP config); re-exported +// here so existing importers keep working. -export function isSmtpConfigured(): boolean { - return !!(process.env.SMTP_HOST || getAppSetting('smtp_host')); -} +export { isSmtpConfigured }; export function isWebhookConfigured(): boolean { return getActiveChannels().includes('webhook'); diff --git a/server/src/services/notificationService.ts b/server/src/services/notificationService.ts index b79dd2b17..1a4004245 100644 --- a/server/src/services/notificationService.ts +++ b/server/src/services/notificationService.ts @@ -4,25 +4,12 @@ import { getActiveChannels, isEnabledForEvent, getAdminGlobalPref, - isSmtpConfigured, + isAdminGlobalChannel, ADMIN_SCOPED_EVENTS, type NotifEventType, - type NotifChannel, } from './notificationPreferencesService'; -import { - getEventText, - sendEmail, - sendWebhook, - sendNtfy, - getUserEmail, - getUserLanguage, - getUserWebhookUrl, - getAdminWebhookUrl, - getUserNtfyConfig, - getAdminNtfyConfig, - resolveNtfyUrl, - getAppUrl, -} from './notifications'; +import { getEventText, getUserLanguage, getAppUrl } from './notifications'; +import { listChannels, type ChannelMessage, type ExternalChannel } from './notifications/channelRegistry'; import { resolveRecipients, createNotificationForRecipient, @@ -177,6 +164,40 @@ export interface NotificationPayload { }; } +/** + * Should this channel deliver this event to this recipient? + * + * Encodes, unchanged, the gating the four hand-written dispatch blocks used to do: + * - admin-scoped events (version_available) reach recipients only over a channel + * that bypasses the `notification_channels` toggle (email), gated by the admin + * global pref rather than the per-user opt-out. Their webhook/ntfy copies go out + * once, globally, via sendGlobal() below — not per recipient. + * - everything else: the admin enabled the channel, the user didn't opt out of this + * event on it, and the user has credentials for it. + */ +function shouldSendToUser( + channel: ExternalChannel, + event: NotifEventType, + recipientId: number, + activeChannels: string[], +): boolean { + if (!channel.supportsEvent(event)) return false; + if (channel.isInstanceConfigured && !channel.isInstanceConfigured()) return false; + + if (ADMIN_SCOPED_EVENTS.has(event)) { + if (!channel.bypassesActiveToggleForAdminEvents) return false; + if (!isAdminGlobalChannel(channel.id)) return false; + if (!getAdminGlobalPref(event, channel.id)) return false; + } else { + // A built-in needs the admin's explicit switch; a plugin channel is on because the + // admin enabled the plugin — that IS the opt-in (see getActiveChannels). + if (channel.source === 'builtin' && !activeChannels.includes(channel.id)) return false; + if (!isEnabledForEvent(recipientId, event, channel.id)) return false; + } + + return channel.isConfiguredFor(recipientId); +} + export async function send(payload: NotificationPayload): Promise { const { event, actorId, params, scope, targetId, inApp } = payload; @@ -202,6 +223,7 @@ export async function send(payload: NotificationPayload): Promise { } const config = configEntry ?? FALLBACK_EVENT_CONFIG; const activeChannels = getActiveChannels(); + const channels = listChannels(); const appUrl = getAppUrl(); // Build navigate target (used by email/webhook CTA and in-app navigate) @@ -273,43 +295,23 @@ export async function send(payload: NotificationPayload): Promise { ); } - // ── Email ──────────────────────────────────────────────────────────── - // Admin-scoped events: use global pref + SMTP check (bypass notification_channels toggle) - // Regular events: use active channels + per-user pref - const emailEnabled = ADMIN_SCOPED_EVENTS.has(event) - ? isSmtpConfigured() && getAdminGlobalPref(event, 'email') - : activeChannels.includes('email') && isEnabledForEvent(recipientId, event, 'email'); - - if (emailEnabled) { - const email = getUserEmail(recipientId); - if (email) { - const lang = getUserLanguage(recipientId); - const { title, body } = getEventText(lang, event, params); - promises.push(sendEmail(email, title, body, recipientId, navigateTarget ?? undefined)); - } - } - - // ── Webhook (per-user) — skip for admin-scoped events (handled globally below) ── - if (!ADMIN_SCOPED_EVENTS.has(event) && activeChannels.includes('webhook') && isEnabledForEvent(recipientId, event, 'webhook')) { - const webhookUrl = getUserWebhookUrl(recipientId); - if (webhookUrl) { - const lang = getUserLanguage(recipientId); - const { title, body } = getEventText(lang, event, params); - promises.push(sendWebhook(webhookUrl, { event, title, body, tripName: params.trip, link: fullLink })); - } - } - - // ── 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 })); - } + // ── External channels (email, webhook, ntfy, plugin:*) ─────────────── + // One loop over the registry. The message is rendered once per recipient, in + // their language, and handed to every channel that wants it — so a plugin + // channel never touches i18n. + const deliverable = channels.filter(ch => shouldSendToUser(ch, event, recipientId, activeChannels)); + if (deliverable.length > 0) { + const lang = getUserLanguage(recipientId); + const { title, body } = getEventText(lang, event, params); + const msg: ChannelMessage = { + event, + title, + body, + navigateTarget: navigateTarget ?? undefined, + url: fullLink, + tripName: params.trip, + }; + for (const ch of deliverable) promises.push(ch.sendToUser(recipientId, msg)); } const results = await Promise.allSettled(promises); @@ -320,26 +322,23 @@ export async function send(payload: NotificationPayload): Promise { } })); - // ── Admin webhook (scope: admin) — global, respects global pref ────── - if (scope === 'admin' && getAdminGlobalPref(event, 'webhook')) { - const adminWebhookUrl = getAdminWebhookUrl(); - if (adminWebhookUrl) { + // ── Admin-global copies (scope: admin) ─────────────────────────────── + // One send per channel, over the admin's own credentials, not per-recipient. + // Always rendered in English — there is no single recipient to take a language from. + if (scope === 'admin') { + const globalChannels = channels.filter( + ch => ch.sendGlobal && ch.supportsEvent(event) && isAdminGlobalChannel(ch.id) && getAdminGlobalPref(event, ch.id), + ); + if (globalChannels.length > 0) { const { title, body } = getEventText('en', event, params); - await sendWebhook(adminWebhookUrl, { event, title, body, link: fullLink }).catch((err: unknown) => { - logError(`notificationService.send admin webhook failed event=${event}: ${err instanceof Error ? err.message : err}`); - }); - } - } - - // ── 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}`); - }); + const msg: ChannelMessage = { event, title, body, navigateTarget: navigateTarget ?? undefined, url: fullLink }; + await Promise.all( + globalChannels.map(ch => + ch.sendGlobal!(msg).catch((err: unknown) => { + logError(`notificationService.send admin ${ch.id} failed event=${event}: ${err instanceof Error ? err.message : err}`); + }), + ), + ); } } } diff --git a/server/src/services/notifications.ts b/server/src/services/notifications.ts index c256ce038..c2a72d1dd 100644 --- a/server/src/services/notifications.ts +++ b/server/src/services/notifications.ts @@ -92,6 +92,11 @@ export function getMcpSafeUrl(): string { return `http://localhost:${port}`; } +/** Is SMTP configured at the instance level? (Independent of any one user's address.) */ +export function isSmtpConfigured(): boolean { + return !!(process.env.SMTP_HOST || getAppSetting('smtp_host')); +} + export function getUserEmail(userId: number): string | null { // Defense-in-depth (#1362): a guest's synthetic email must never be emailed. return (db.prepare('SELECT email FROM users WHERE id = ? AND COALESCE(is_guest, 0) = 0').get(userId) as { email: string } | undefined)?.email || null; diff --git a/server/src/services/notifications/builtins.ts b/server/src/services/notifications/builtins.ts new file mode 100644 index 000000000..8846e35bf --- /dev/null +++ b/server/src/services/notifications/builtins.ts @@ -0,0 +1,117 @@ +import { + sendEmail, + sendWebhook, + sendNtfy, + testSmtp, + testWebhook, + testNtfy, + isSmtpConfigured, + getUserEmail, + getUserWebhookUrl, + getAdminWebhookUrl, + getUserNtfyConfig, + getAdminNtfyConfig, + resolveNtfyUrl, +} from '../notifications'; +import { registerChannel, type ChannelMessage, type ExternalChannel } from './channelRegistry'; + +// The three built-in external channels, wrapping the send functions and config +// resolvers that already live in ../notifications.ts. No delivery logic is +// rewritten here — it is only relocated behind the ExternalChannel interface so +// notificationService.send() can iterate instead of branching. + +/** + * Every event except `synology_session_cleared` is deliverable externally. + * This is the pre-registry IMPLEMENTED_COMBOS table, transposed: it listed + * ['inapp','email','webhook','ntfy'] for every event and ['inapp'] for that one. + */ +function supportsAllButSynology(event: string): boolean { + return event !== 'synology_session_cleared'; +} + +const emailChannel: ExternalChannel = { + id: 'email', + source: 'builtin', + labelKey: 'settings.notificationPreferences.email', + // Admin-scoped events (version_available) reach admins by email even when the + // admin has not put `email` in notification_channels — the admin global pref + // gates it instead. Verbatim from the pre-registry dispatch. + bypassesActiveToggleForAdminEvents: true, + supportsEvent: supportsAllButSynology, + isInstanceConfigured: isSmtpConfigured, + isConfiguredFor: (userId) => !!getUserEmail(userId), + async sendToUser(userId, msg) { + const email = getUserEmail(userId); + if (!email) return false; + return sendEmail(email, msg.title, msg.body, userId, msg.navigateTarget); + }, + async test(userId) { + const email = getUserEmail(userId); + if (!email) return { success: false, error: 'No email address on file' }; + return testSmtp(email); + }, +}; + +const webhookChannel: ExternalChannel = { + id: 'webhook', + source: 'builtin', + labelKey: 'settings.notificationPreferences.webhook', + supportsAdminGlobal: true, + supportsEvent: supportsAllButSynology, + isConfiguredFor: (userId) => !!getUserWebhookUrl(userId), + async sendToUser(userId, msg) { + const url = getUserWebhookUrl(userId); + if (!url) return false; + return sendWebhook(url, { event: msg.event, title: msg.title, body: msg.body, tripName: msg.tripName, link: msg.url }); + }, + async sendGlobal(msg: ChannelMessage) { + const url = getAdminWebhookUrl(); + if (!url) return false; + return sendWebhook(url, { event: msg.event, title: msg.title, body: msg.body, link: msg.url }); + }, + async test(userId, override) { + const url = (typeof override?.url === 'string' && override.url) || getUserWebhookUrl(userId); + if (!url) return { success: false, error: 'No webhook URL configured' }; + return testWebhook(url); + }, +}; + +const ntfyChannel: ExternalChannel = { + id: 'ntfy', + source: 'builtin', + labelKey: 'settings.notificationPreferences.ntfy', + supportsAdminGlobal: true, + supportsEvent: supportsAllButSynology, + isConfiguredFor: (userId) => !!resolveNtfyUrl(getAdminNtfyConfig(), getUserNtfyConfig(userId)), + async sendToUser(userId, msg) { + const userCfg = getUserNtfyConfig(userId); + const adminCfg = getAdminNtfyConfig(); + const url = resolveNtfyUrl(adminCfg, userCfg); + if (!url) return false; + return sendNtfy(url, userCfg?.token ?? adminCfg.token, { event: msg.event, title: msg.title, body: msg.body, link: msg.url }); + }, + async sendGlobal(msg: ChannelMessage) { + const adminCfg = getAdminNtfyConfig(); + const url = resolveNtfyUrl(adminCfg, null); + if (!url) return false; + return sendNtfy(url, adminCfg.token, { event: msg.event, title: msg.title, body: msg.body, link: msg.url }); + }, + async test(userId, override) { + const topic = typeof override?.topic === 'string' ? override.topic : getUserNtfyConfig(userId)?.topic; + if (!topic) return { success: false, error: 'Could not resolve ntfy URL — missing topic' }; + return testNtfy({ + topic, + server: typeof override?.server === 'string' ? override.server : null, + token: typeof override?.token === 'string' ? override.token : null, + }); + }, +}; + +export const BUILTIN_CHANNELS = [emailChannel, webhookChannel, ntfyChannel]; + +/** Idempotent — safe to call from every entry point that needs the registry populated. */ +export function registerBuiltinChannels(): void { + for (const channel of BUILTIN_CHANNELS) registerChannel(channel); +} + +registerBuiltinChannels(); diff --git a/server/src/services/notifications/channelRegistry.ts b/server/src/services/notifications/channelRegistry.ts new file mode 100644 index 000000000..9df12c6d5 --- /dev/null +++ b/server/src/services/notifications/channelRegistry.ts @@ -0,0 +1,164 @@ +import type { NotifEventType } from '../notificationPreferencesService'; + +// ── External notification channels ───────────────────────────────────────── +// +// A channel is anything that delivers a *rendered* notification out of TREK: +// email, webhook, ntfy, and any plugin that implements the `notificationChannel` +// hook. In-app is deliberately NOT a channel — it writes typed rows with +// scope/target/callback payloads rather than a title+body, so it keeps its own +// path in notificationService.send(). The split mirrors the one shared/ already +// draws in i18n//externalNotifications.ts. +// +// Channels are declarative: they say what they support and whether a given user +// has credentials, and they send. They do NOT consult preferences — the admin +// toggle (`notification_channels`) and the per-user event opt-outs are applied by +// the dispatch loop. That keeps this module free of any runtime dependency on +// notificationPreferencesService, which in turn imports listChannels() from here. + +/** A notification already rendered into the recipient's language. */ +export interface ChannelMessage { + event: NotifEventType; + title: string; + body: string; + /** Relative navigate target (e.g. `/trips/12`) — what the email CTA builder takes. */ + navigateTarget?: string; + /** Absolute link (appUrl + navigateTarget) — what webhook/ntfy/plugin payloads carry. */ + url?: string; + tripName?: string; +} + +export interface ExternalChannel { + readonly id: string; + readonly source: 'builtin' | 'plugin'; + /** Built-ins: an i18n key the client resolves. */ + readonly labelKey?: string; + /** Plugin channels: a literal display name (the host has no i18n for it). */ + readonly label?: string; + /** Where the user configures their credentials, if anywhere. */ + readonly settingsPath?: string; + /** + * Admin-scoped events (`version_available`) bypass the `notification_channels` + * toggle for this channel and are gated by the admin global pref instead. + * Only email does this today; preserved verbatim from the pre-registry dispatch. + */ + readonly bypassesActiveToggleForAdminEvents?: boolean; + /** Delivers the one admin-scoped global copy (not per-recipient). */ + readonly supportsAdminGlobal?: boolean; + + supportsEvent(event: NotifEventType): boolean; + /** + * Instance-level readiness, independent of any one recipient (email: is SMTP set + * up at all?). Absent means "always ready". + */ + isInstanceConfigured?(): boolean; + /** Does this recipient have credentials for this channel? */ + isConfiguredFor(userId: number): boolean; + sendToUser(userId: number, msg: ChannelMessage): Promise; + sendGlobal?(msg: ChannelMessage): Promise; + test?(userId: number, override?: Record): Promise<{ success: boolean; error?: string }>; +} + +/** Namespace for plugin channel ids, so a plugin can never claim `email`. */ +export const PLUGIN_CHANNEL_PREFIX = 'plugin:'; + +export function pluginChannelId(pluginId: string): string { + return `${PLUGIN_CHANNEL_PREFIX}${pluginId}`; +} + +export function isPluginChannelId(id: string): boolean { + return id.startsWith(PLUGIN_CHANNEL_PREFIX); +} + +// ── Registry ─────────────────────────────────────────────────────────────── + +const builtins = new Map(); + +/** Register a built-in channel. Called at import of ./builtins. */ +export function registerChannel(channel: ExternalChannel): void { + // The `plugin:` namespace belongs to plugins; a built-in claiming it would make + // getChannel() resolve inconsistently (it routes namespaced ids past this map). + if (isPluginChannelId(channel.id)) throw new Error(`built-in channel cannot claim the plugin namespace: ${channel.id}`); + builtins.set(channel.id, channel); +} + +/** + * Plugin channels cross a trust boundary, so re-establish their invariants HERE + * rather than trusting whoever produced them. + * + * The runtime mints ids with pluginChannelId() and leaves the built-in-only privileges + * off — but nothing structural stopped a bug (or a future caller) from handing us a + * channel that claims `email`. Such a channel would be dispatched to off the user's + * EMAIL opt-in, without them ever enabling a plugin channel, and — because + * isAdminGlobalChannel('email') is true — would also receive admin-scoped events and + * the admin-global copy. Same reasoning as providersOf()/invokeHook re-checking the + * hook grant host-side even though the child reports its own hooks. + */ +function sanitizePluginChannels(raw: readonly ExternalChannel[]): ExternalChannel[] { + const out: ExternalChannel[] = []; + const seen = new Set(); + for (const c of raw) { + if (!c || typeof c.id !== 'string') continue; + if (!isPluginChannelId(c.id)) continue; // must be namespaced … + if (builtins.has(c.id)) continue; // … and can never shadow a built-in + if (seen.has(c.id)) continue; // no duplicate columns / double sends + seen.add(c.id); + out.push({ + id: c.id, + source: 'plugin', + label: c.label, + settingsPath: c.settingsPath, + // Built-in-only privileges, stripped unconditionally. A plugin channel is + // user-scoped: it never bypasses the admin's channel toggle, and never carries an + // admin-scoped or admin-global notification. (With these off, notificationService + // refuses it an ADMIN_SCOPED_EVENT and the admin-global loop skips it — so this + // holds even if the channel lies in supportsEvent().) + bypassesActiveToggleForAdminEvents: false, + supportsAdminGlobal: false, + // Bound, so a producer that used a class keeps its `this`. + supportsEvent: (e) => c.supportsEvent(e), + isConfiguredFor: (u) => c.isConfiguredFor(u), + sendToUser: (u, m) => c.sendToUser(u, m), + ...(c.isInstanceConfigured ? { isInstanceConfigured: () => c.isInstanceConfigured!() } : {}), + ...(c.test ? { test: (u: number, o?: Record) => c.test!(u, o) } : {}), + // NB: sendGlobal is deliberately NOT carried over. + }); + } + return out; +} + +// Plugin channels are supplied by the plugin runtime, which lives in the Nest +// graph and cannot be imported from a plain service. Same seam as +// setPluginEventSink() in plugin-runtime.service.ts: the runtime pushes a getter +// in at onModuleInit, and we pull from it on every read so a plugin that is +// disabled or uninstalled simply stops appearing. +let pluginChannelSource: (() => ExternalChannel[]) | null = null; + +export function setPluginChannelSource(source: (() => ExternalChannel[]) | null): void { + pluginChannelSource = source; +} + +/** Every channel that currently exists: built-ins plus live, sanitized plugin channels. */ +export function listChannels(): ExternalChannel[] { + const plugins = (() => { + try { + return pluginChannelSource?.() ?? []; + } catch { + // A broken plugin runtime must never take notifications down with it. + return []; + } + })(); + // Built-ins first, and they always win: a plugin channel can only ever be an + // addition, never an override. + return [...builtins.values(), ...sanitizePluginChannels(plugins)]; +} + +export function getChannel(id: string): ExternalChannel | undefined { + if (isPluginChannelId(id)) return listChannels().find((c) => c.id === id); + return builtins.get(id); +} + +/** Test seam — drops registered built-ins. */ +export function __resetChannelsForTest(): void { + builtins.clear(); + pluginChannelSource = null; +} diff --git a/server/tests/integration/notifications.test.ts b/server/tests/integration/notifications.test.ts index 46ea06842..1dc5ade55 100644 --- a/server/tests/integration/notifications.test.ts +++ b/server/tests/integration/notifications.test.ts @@ -175,17 +175,22 @@ describe('In-app notifications', () => { // ───────────────────────────────────────────────────────────────────────────── describe('GET /api/notifications/preferences — matrix format', () => { - it('NROUTE-002 — returns preferences, available_channels, event_types, implemented_combos', async () => { + it('NROUTE-002 — returns preferences, channels, event_types, implemented_combos', async () => { const { user } = createUser(testDb); const res = await request(app) .get('/api/notifications/preferences') .set('Cookie', authCookie(user.id)); expect(res.status).toBe(200); expect(res.body).toHaveProperty('preferences'); - expect(res.body).toHaveProperty('available_channels'); + expect(res.body).toHaveProperty('channels'); expect(res.body).toHaveProperty('event_types'); expect(res.body).toHaveProperty('implemented_combos'); - expect(res.body.available_channels.inapp).toBe(true); + const inapp = res.body.channels.find((c: { id: string }) => c.id === 'inapp'); + expect(inapp.active).toBe(true); + // The built-in external channels are always described, active or not. + expect(res.body.channels.map((c: { id: string }) => c.id)).toEqual( + expect.arrayContaining(['inapp', 'email', 'webhook', 'ntfy']), + ); }); it('NROUTE-003 — regular user does not see version_available in event_types', async () => { diff --git a/server/tests/integration/plugins/dev-link.test.ts b/server/tests/integration/plugins/dev-link.test.ts index c73b2d6f2..35f9dfefd 100644 --- a/server/tests/integration/plugins/dev-link.test.ts +++ b/server/tests/integration/plugins/dev-link.test.ts @@ -16,7 +16,7 @@ const { testDb } = vi.hoisted(() => { db.exec(`CREATE TABLE plugins ( id TEXT PRIMARY KEY, name TEXT, description TEXT, type TEXT, icon TEXT, version TEXT, api_version INTEGER, min_trek_version TEXT, permissions TEXT DEFAULT '[]', capabilities TEXT DEFAULT '{}', dependencies TEXT DEFAULT '{}', - granted_permissions TEXT DEFAULT '', status TEXT, enabled INTEGER DEFAULT 0, config TEXT DEFAULT '{}', + operator_egress INTEGER DEFAULT 0, granted_permissions TEXT DEFAULT '', status TEXT, enabled INTEGER DEFAULT 0, config TEXT DEFAULT '{}', source_repo TEXT, source_commit TEXT, sha256 TEXT, author_pubkey TEXT, reviewed_at TEXT, last_error TEXT, updated_at TEXT); CREATE TABLE plugin_error_log (id INTEGER PRIMARY KEY AUTOINCREMENT, plugin_id TEXT, level TEXT, message TEXT, ts TEXT); CREATE TABLE plugin_settings_fields (plugin_id TEXT, field_key TEXT, label TEXT, input_type TEXT, placeholder TEXT, hint TEXT, required INTEGER, secret INTEGER, scope TEXT, options TEXT, oauth_config TEXT, sort_order INTEGER); diff --git a/server/tests/integration/plugins/plugin-runtime.test.ts b/server/tests/integration/plugins/plugin-runtime.test.ts index 6a9c5f852..a6cbc2e2f 100644 --- a/server/tests/integration/plugins/plugin-runtime.test.ts +++ b/server/tests/integration/plugins/plugin-runtime.test.ts @@ -12,7 +12,7 @@ const { testDb } = vi.hoisted(() => { const Database = require('better-sqlite3'); const db = new Database(':memory:'); db.exec(`CREATE TABLE plugins ( - id TEXT PRIMARY KEY, status TEXT, enabled INTEGER DEFAULT 0, version TEXT, permissions TEXT DEFAULT '[]', granted_permissions TEXT DEFAULT '', + id TEXT PRIMARY KEY, status TEXT, enabled INTEGER DEFAULT 0, version TEXT, permissions TEXT DEFAULT '[]', operator_egress INTEGER DEFAULT 0, granted_permissions TEXT DEFAULT '', config TEXT DEFAULT '{}', dependencies TEXT DEFAULT '{}', capabilities TEXT DEFAULT '{}', last_error TEXT, updated_at TEXT); CREATE TABLE plugin_error_log (id INTEGER PRIMARY KEY AUTOINCREMENT, plugin_id TEXT, level TEXT, message TEXT, ts TEXT); CREATE TABLE plugin_settings_fields (plugin_id TEXT, field_key TEXT, scope TEXT, secret INTEGER); diff --git a/server/tests/integration/plugins/registry.test.ts b/server/tests/integration/plugins/registry.test.ts index 22f702ae6..44aa8bcfa 100644 --- a/server/tests/integration/plugins/registry.test.ts +++ b/server/tests/integration/plugins/registry.test.ts @@ -21,7 +21,7 @@ const { testDb } = vi.hoisted(() => { const db = new Database(':memory:'); db.exec(` CREATE TABLE plugins (id TEXT PRIMARY KEY, name TEXT, description TEXT, type TEXT, icon TEXT, version TEXT, - api_version INTEGER, min_trek_version TEXT, permissions TEXT, capabilities TEXT DEFAULT '{}', dependencies TEXT DEFAULT '{}', granted_permissions TEXT, status TEXT, enabled INTEGER DEFAULT 0, config TEXT, + api_version INTEGER, min_trek_version TEXT, permissions TEXT, capabilities TEXT DEFAULT '{}', dependencies TEXT DEFAULT '{}', operator_egress INTEGER DEFAULT 0, granted_permissions TEXT, status TEXT, enabled INTEGER DEFAULT 0, config TEXT, source_repo TEXT, source_commit TEXT, sha256 TEXT, reviewed_at TEXT, author_pubkey TEXT, updated_at TEXT); CREATE TABLE plugin_settings_fields (plugin_id TEXT, field_key TEXT, label TEXT, input_type TEXT, placeholder TEXT, hint TEXT, required INTEGER, secret INTEGER, scope TEXT, options TEXT, oauth_config TEXT, sort_order INTEGER); CREATE TABLE plugin_error_log (id INTEGER PRIMARY KEY AUTOINCREMENT, plugin_id TEXT, level TEXT, message TEXT, ts TEXT);`); @@ -165,6 +165,24 @@ describe('PluginRegistryService', () => { expect(d.manifest).toMatchObject({ permissions: [], egress: ['ok.host'], settings: [] }); }); + it('detail surfaces operatorEgress, so the pre-install review can say the host list is not the whole story', async () => { + safeDownload.mockResolvedValue({ + bytes: Buffer.from(JSON.stringify({ permissions: ['http:outbound:gotify.net'], egress: ['gotify.net'], operatorEgress: true })), + sha256: 'unused', + }); + const d = (await svc.detail('flight-tracker')) as { manifest: { operatorEgress: boolean; egress: string[] } }; + expect(d.manifest).toMatchObject({ egress: ['gotify.net'], operatorEgress: true }); + }); + + it('detail defaults operatorEgress to false (an ordinary plugin must not claim it)', async () => { + safeDownload.mockResolvedValue({ + bytes: Buffer.from(JSON.stringify({ permissions: [], egress: ['api.example.com'] })), + sha256: 'unused', + }); + const d = (await svc.detail('flight-tracker')) as { manifest: { operatorEgress: boolean } }; + expect(d.manifest.operatorEgress).toBe(false); + }); + it('detail is cached per plugin (one manifest fetch across calls)', async () => { safeDownload.mockResolvedValue({ bytes: Buffer.from('{}'), sha256: 'unused' }); await svc.detail('flight-tracker'); diff --git a/server/tests/unit/plugins/discovery.test.ts b/server/tests/unit/plugins/discovery.test.ts index a8d361013..81b54c61e 100644 --- a/server/tests/unit/plugins/discovery.test.ts +++ b/server/tests/unit/plugins/discovery.test.ts @@ -27,9 +27,11 @@ beforeEach(() => { db = new Database(':memory:'); db.exec(` CREATE TABLE plugins (id TEXT PRIMARY KEY, name TEXT, description TEXT, type TEXT, icon TEXT, version TEXT, - api_version INTEGER, min_trek_version TEXT, permissions TEXT, capabilities TEXT DEFAULT '{}', dependencies TEXT DEFAULT '{}', granted_permissions TEXT, status TEXT, config TEXT, updated_at TEXT); + api_version INTEGER, min_trek_version TEXT, permissions TEXT, capabilities TEXT DEFAULT '{}', dependencies TEXT DEFAULT '{}', operator_egress INTEGER DEFAULT 0, granted_permissions TEXT, status TEXT, config TEXT, updated_at TEXT); CREATE TABLE plugin_settings_fields (plugin_id TEXT, field_key TEXT, label TEXT, input_type TEXT, placeholder TEXT, hint TEXT, required INTEGER, secret INTEGER, scope TEXT, options TEXT, oauth_config TEXT, sort_order INTEGER); + CREATE TABLE plugin_actions (plugin_id TEXT, action_key TEXT, label TEXT, hint TEXT, danger INTEGER, sort_order INTEGER, + PRIMARY KEY (plugin_id, action_key)); CREATE TABLE plugin_error_log (id INTEGER PRIMARY KEY AUTOINCREMENT, plugin_id TEXT, level TEXT, message TEXT, ts TEXT);`); }); afterEach(() => { diff --git a/server/tests/unit/plugins/manifest.test.ts b/server/tests/unit/plugins/manifest.test.ts index 90b76f036..9a04fd382 100644 --- a/server/tests/unit/plugins/manifest.test.ts +++ b/server/tests/unit/plugins/manifest.test.ts @@ -141,3 +141,99 @@ describe('parseManifest dependencies', () => { expect(() => parseManifest({ ...base, pluginDependencies: [{ id: 'koffi', version: '*' }, { id: 'koffi', version: '^1' }] })).toThrow(/duplicate/); }); }); + +describe('capabilities.notificationChannel', () => { + const base = { + id: 'my-gotify', + name: 'My Gotify', + version: '1.0.0', + apiVersion: 1, + type: 'integration', + nativeModules: false, + permissions: ['hook:notification-channel', 'http:outbound:gotify.example.com'], + egress: ['gotify.example.com'], + }; + + it('accepts what the SDK notification-channel template scaffolds', () => { + const m = parseManifest({ + ...base, + capabilities: { notificationChannel: { title: 'My Gotify' } }, + settings: [{ key: 'appToken', label: 'App token', required: true, secret: true, scope: 'user' }], + }); + expect(m.capabilities.notificationChannel?.title).toBe('My Gotify'); + // scope:'user' + secret is what lets the host hand the decrypted value to the hook. + expect(m.settings[0].scope).toBe('user'); + expect(m.settings[0].secret).toBe(true); + }); + + it('accepts a narrowed event list', () => { + const m = parseManifest({ ...base, capabilities: { notificationChannel: { events: ['trip_invite', 'booking_change'] } } }); + expect(m.capabilities.notificationChannel?.events).toEqual(['trip_invite', 'booking_change']); + }); + + it('rejects an admin-scoped event — a plugin channel can never carry one', () => { + expect(() => parseManifest({ ...base, capabilities: { notificationChannel: { events: ['version_available'] } } })) + .toThrow(/not a plugin-deliverable event/); + }); + + it('rejects a non-array events field', () => { + expect(() => parseManifest({ ...base, capabilities: { notificationChannel: { events: 'trip_invite' } } })) + .toThrow(ManifestError); + }); +}); + +describe('select field options', () => { + const base = { id: 'sel', name: 'Sel', version: '1.0.0', apiVersion: 1, type: 'integration', nativeModules: false, permissions: [] }; + const opts = (options: unknown) => parseManifest({ ...base, settings: [{ key: 'priority', input_type: 'select', scope: 'user', options }] }).settings[0].options; + + it('keeps a proper { value, label } list', () => { + expect(opts([{ value: '5', label: 'Normal' }])).toEqual([{ value: '5', label: 'Normal' }]); + }); + + it('coerces a bare string/number list instead of rendering blank options', () => { + // The obvious thing to write — and it used to be cast straight through, so the + // client read o.value/o.label as undefined and every dropdown entry was EMPTY. + expect(opts(['1', '5'])).toEqual([{ value: '1', label: '1' }, { value: '5', label: '5' }]); + expect(opts([1, 5])).toEqual([{ value: '1', label: '1' }, { value: '5', label: '5' }]); + }); + + it('defaults a missing label to the value rather than leaving it blank', () => { + expect(opts([{ value: '5' }])).toEqual([{ value: '5', label: '5' }]); + }); + + it('rejects an option with no value, and a non-array list', () => { + expect(() => opts([{ label: 'Normal' }])).toThrow(/non-empty "value"/); + expect(() => opts([null])).toThrow(ManifestError); + expect(() => opts('nope')).toThrow(/must be an array/); + }); +}); + +describe('settings-page actions', () => { + const base = { id: 'act', name: 'Act', version: '1.0.0', apiVersion: 1, type: 'integration', nativeModules: false, permissions: [] }; + + it('parses actions with label/hint/danger', () => { + const m = parseManifest({ ...base, actions: [{ key: 'testConnection', label: 'Test connection', hint: 'Pings the API.' }, { key: 'purge', label: 'Purge', danger: true }] }); + expect(m.actions).toEqual([ + { key: 'testConnection', label: 'Test connection', hint: 'Pings the API.', danger: false }, + { key: 'purge', label: 'Purge', hint: undefined, danger: true }, + ]); + }); + + it('defaults the label to the key, and bounds label/hint', () => { + const m = parseManifest({ ...base, actions: [{ key: 'sync', label: 'L'.repeat(200), hint: 'H'.repeat(500) }] }); + expect(m.actions[0].label.length).toBe(60); + expect(m.actions[0].hint!.length).toBe(200); + expect(parseManifest({ ...base, actions: [{ key: 'sync' }] }).actions[0].label).toBe('sync'); + }); + + it('rejects a prototype-chain key, a duplicate, a non-array and too many', () => { + expect(() => parseManifest({ ...base, actions: [{ key: '__proto__' }] })).toThrow(ManifestError); + expect(() => parseManifest({ ...base, actions: [{ key: 'a' }, { key: 'a' }] })).toThrow(/duplicate action/); + expect(() => parseManifest({ ...base, actions: 'nope' })).toThrow(/must be an array/); + expect(() => parseManifest({ ...base, actions: Array.from({ length: 9 }, (_, i) => ({ key: `a${i}` })) })).toThrow(/at most 8/); + }); + + it('defaults to no actions', () => { + expect(parseManifest(base).actions).toEqual([]); + }); +}); diff --git a/server/tests/unit/plugins/operator-egress.test.ts b/server/tests/unit/plugins/operator-egress.test.ts new file mode 100644 index 000000000..9a39d04dd --- /dev/null +++ b/server/tests/unit/plugins/operator-egress.test.ts @@ -0,0 +1,149 @@ +/** + * Operator-supplied egress hosts. + * + * A plugin's egress allow-list is fixed in its manifest at publish time, but a plugin that + * talks to a SELF-HOSTED service (Gotify, ntfy, …) cannot know the operator's hostname — + * so a community plugin would serve nobody. An ADMIN adds the hosts post-install and the + * runtime unions them into the child's allow-list at spawn. + * + * The invariants that keep this from becoming an egress bypass: + * - only a plugin that DECLARED operatorEgress may have hosts (install-time consent); + * - hosts are validated like manifest egress (no bare `*`, no scheme, no whole-TLD); + * - it is always the ADMIN, never an end user, who widens it; + * - changing the set RE-SPAWNS the plugin, because the child's guard is install-once. + */ +import { describe, it, expect, vi, beforeAll, beforeEach } from 'vitest'; + +const { testDb, dbMock } = vi.hoisted(() => { + const Database = require('better-sqlite3'); + const db = new Database(':memory:'); + return { testDb: db, dbMock: { db, closeDb: () => {}, reinitialize: () => {}, canAccessTrip: () => null } }; +}); +vi.mock('../../../src/db/database', () => dbMock); +vi.mock('../../../src/config', () => ({ JWT_SECRET: 'x'.repeat(40), ENCRYPTION_KEY: 'a'.repeat(64), updateJwtSecret: () => {} })); + +import { createTables } from '../../../src/db/schema'; +import { runMigrations } from '../../../src/db/migrations'; +import { PluginRuntimeService } from '../../../src/nest/plugins/plugin-runtime.service'; +import { parseManifest, ManifestError } from '../../../src/nest/plugins/install/manifest'; + +function install(id: string, operatorEgress: boolean, perms: string[] = ['http:outbound:gotify.net']) { + testDb.prepare( + `INSERT OR REPLACE INTO plugins (id, name, status, enabled, version, permissions, granted_permissions, capabilities, config, operator_egress) + VALUES (?, ?, 'inactive', 0, '1.0.0', ?, ?, '{}', '{}', ?)`, + ).run(id, id, JSON.stringify(perms), JSON.stringify(perms), operatorEgress ? 1 : 0); +} + +let rt: PluginRuntimeService; + +beforeAll(() => { createTables(testDb); runMigrations(testDb); }); +beforeEach(() => { + testDb.prepare('DELETE FROM plugins').run(); + testDb.prepare('DELETE FROM plugin_egress_hosts').run(); + testDb.prepare('DELETE FROM plugin_actions').run(); + rt = new PluginRuntimeService(); +}); + +describe('operator-supplied egress hosts', () => { + it('OEG-001 — an admin can add hosts to a plugin that declared operatorEgress', async () => { + install('gotify', true); + expect(rt.wantsOperatorEgress('gotify')).toBe(true); + expect(await rt.setOperatorEgressHosts('gotify', ['gotify.mydomain.com'])).toEqual(['gotify.mydomain.com']); + expect(rt.operatorEgressHosts('gotify')).toEqual(['gotify.mydomain.com']); + }); + + it('OEG-002 — a plugin that did NOT declare it can never have hosts added', async () => { + install('sneaky', false); + expect(rt.wantsOperatorEgress('sneaky')).toBe(false); + // This is the load-bearing check: without it an admin could silently widen egress for + // ANY plugin, and the install-time consent would stop bounding what's possible. + await expect(rt.setOperatorEgressHosts('sneaky', ['evil.example.com'])).rejects.toThrow(/did not declare operatorEgress/); + expect(rt.operatorEgressHosts('sneaky')).toEqual([]); + }); + + it('OEG-003 — hosts are validated exactly like manifest egress', async () => { + install('gotify', true); + for (const bad of ['*', '*.com', 'https://gotify.example.com', 'has space', 'a/b']) { + await expect(rt.setOperatorEgressHosts('gotify', [bad])).rejects.toThrow(/invalid host/); + } + // A legitimate wildcard with a real multi-label suffix is fine. + expect(await rt.setOperatorEgressHosts('gotify', ['*.mydomain.com'])).toEqual(['*.mydomain.com']); + }); + + it('OEG-004 — hosts are normalized and de-duplicated', async () => { + install('gotify', true); + expect(await rt.setOperatorEgressHosts('gotify', ['Gotify.MyDomain.com', 'gotify.mydomain.com.', ' ', ''])).toEqual([ + 'gotify.mydomain.com', + ]); + }); + + it('OEG-005 — setting the list replaces it (a removed host is really gone)', async () => { + install('gotify', true); + await rt.setOperatorEgressHosts('gotify', ['a.example.com', 'b.example.com']); + await rt.setOperatorEgressHosts('gotify', ['b.example.com']); + expect(rt.operatorEgressHosts('gotify')).toEqual(['b.example.com']); + }); + + it('OEG-006 — the manifest rejects operatorEgress without an outbound permission', () => { + const base = { id: 'chan', name: 'Chan', version: '1.0.0', apiVersion: 1, type: 'integration', nativeModules: false }; + expect(() => parseManifest({ ...base, permissions: ['db:own'], operatorEgress: true })).toThrow(/requires an http:outbound/); + expect(() => parseManifest({ ...base, permissions: [], operatorEgress: 'yes' })).toThrow(ManifestError); + // …and accepts the real thing. + const m = parseManifest({ ...base, permissions: ['http:outbound:gotify.net'], egress: ['gotify.net'], operatorEgress: true }); + expect(m.operatorEgress).toBe(true); + }); + + it('OEG-007 — uninstalling drops the admin’s host consent with the plugin', async () => { + install('gotify', true); + await rt.setOperatorEgressHosts('gotify', ['gotify.mydomain.com']); + await rt.uninstall('gotify', false); + // A LATER plugin reusing this id must not silently inherit hosts approved for another. + expect(rt.operatorEgressHosts('gotify')).toEqual([]); + }); +}); + +describe('settings-page actions (runtime)', () => { + function declareAction(id: string, key: string) { + testDb.prepare('INSERT OR REPLACE INTO plugin_actions (plugin_id, action_key, label, hint, danger, sort_order) VALUES (?, ?, ?, NULL, 0, 0)') + .run(id, key, key); + } + + it('ACT-001 — actionsOf returns the declared descriptors', () => { + install('p', false); + declareAction('p', 'testConnection'); + expect(rt.actionsOf('p')).toEqual([{ key: 'testConnection', label: 'testConnection', hint: undefined, danger: false }]); + }); + + it('ACT-002 — invoking an action the plugin never declared is REFUSED', async () => { + install('p', false); + declareAction('p', 'testConnection'); + // The key is caller-supplied (it comes off the URL), so the host must check it + // against the manifest rather than forwarding whatever it is handed to the child. + await expect(rt.invokeAction('p', 'somethingElse', 1)).rejects.toThrow(/did not declare action/); + await expect(rt.invokeAction('p', '__proto__', 1)).rejects.toThrow(/did not declare action/); + }); + + it('ACT-003 — a plugin with no actions can never be invoked', async () => { + install('p', false); + await expect(rt.invokeAction('p', 'testConnection', 1)).rejects.toThrow(/did not declare action/); + }); +}); + +describe('the admin list surfaces operator egress (so the chip can be shown)', () => { + it('OEG-008 — reports operatorEgress + the host count', async () => { + const { PluginsService } = await import('../../../src/nest/plugins/plugins.service'); + process.env.TREK_PLUGINS_ENABLED = 'true'; + install('gotify', true); + install('plain', false); + + const before = new PluginsService().list().plugins; + expect(before.find(p => p.id === 'gotify')).toMatchObject({ operatorEgress: true, egressHostCount: 0 }); + // A plugin that never asked for it must never invite the admin to add hosts. + expect(before.find(p => p.id === 'plain')).toMatchObject({ operatorEgress: false, egressHostCount: 0 }); + + await rt.setOperatorEgressHosts('gotify', ['a.example.com', 'b.example.com']); + const after = new PluginsService().list().plugins; + expect(after.find(p => p.id === 'gotify')!.egressHostCount).toBe(2); + delete process.env.TREK_PLUGINS_ENABLED; + }); +}); diff --git a/server/tests/unit/plugins/plugin-user-settings.controller.test.ts b/server/tests/unit/plugins/plugin-user-settings.controller.test.ts index 3086d4623..3a46bfabf 100644 --- a/server/tests/unit/plugins/plugin-user-settings.controller.test.ts +++ b/server/tests/unit/plugins/plugin-user-settings.controller.test.ts @@ -13,6 +13,7 @@ vi.mock('../../../src/nest/plugins/kill-switch', () => ({ pluginsEnabled })); vi.mock('../../../src/db/database', () => ({ db: { prepare: () => ({ get: getMock }) } })); import { PluginUserSettingsController } from '../../../src/nest/plugins/plugin-user-settings.controller'; +import type { PluginRuntimeService } from '../../../src/nest/plugins/plugin-runtime.service'; import type { PluginsService } from '../../../src/nest/plugins/plugins.service'; // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -23,7 +24,9 @@ function ctrl() { getUserConfig: vi.fn(() => ({ apiKey: '••••••••' })), updateUserConfig: vi.fn((_id: string, _uid: number, patch: Record) => ({ ...patch, apiKey: '••••••••' })), } as unknown as PluginsService; - return { c: new PluginUserSettingsController(svc), svc }; + // The controller now also takes the runtime (for settings-page actions). + const runtime = { actionsOf: () => [], invokeAction: vi.fn() } as unknown as PluginRuntimeService; + return { c: new PluginUserSettingsController(svc, runtime), svc, runtime }; } describe('PluginUserSettingsController', () => { @@ -31,13 +34,13 @@ describe('PluginUserSettingsController', () => { it('GET returns fields + masked config for a bound user; empty when gated', () => { const { c } = ctrl(); - expect(c.get('p', req(5))).toEqual({ fields: [{ key: 'apiKey', secret: true }], config: { apiKey: '••••••••' } }); + expect(c.get('p', req(5))).toEqual({ fields: [{ key: 'apiKey', secret: true }], config: { apiKey: '••••••••' }, actions: [] }); pluginsEnabled.mockReturnValue(false); - expect(c.get('p', req(5))).toEqual({ fields: [], config: {} }); + expect(c.get('p', req(5))).toEqual({ fields: [], config: {}, actions: [] }); pluginsEnabled.mockReturnValue(true); - expect(c.get('p', req(undefined))).toEqual({ fields: [], config: {} }); + expect(c.get('p', req(undefined))).toEqual({ fields: [], config: {}, actions: [] }); getMock.mockReturnValue(undefined as never); - expect(c.get('p', req(5))).toEqual({ fields: [], config: {} }); + expect(c.get('p', req(5))).toEqual({ fields: [], config: {}, actions: [] }); }); it('POST delegates the patch for a bound user; empty when gated', () => { diff --git a/server/tests/unit/plugins/plugins-service.test.ts b/server/tests/unit/plugins/plugins-service.test.ts index fecee0834..642b908d8 100644 --- a/server/tests/unit/plugins/plugins-service.test.ts +++ b/server/tests/unit/plugins/plugins-service.test.ts @@ -9,7 +9,7 @@ const { testDb } = vi.hoisted(() => { const db = new Database(':memory:'); db.exec(`CREATE TABLE plugins ( id TEXT PRIMARY KEY, name TEXT, description TEXT, type TEXT, icon TEXT, version TEXT, - status TEXT, enabled INTEGER DEFAULT 0, last_error TEXT, reviewed_at TEXT, source_repo TEXT, config TEXT DEFAULT '{}', permissions TEXT DEFAULT '[]', capabilities TEXT DEFAULT '{}', dependencies TEXT DEFAULT '{}', updated_at TEXT, + status TEXT, enabled INTEGER DEFAULT 0, last_error TEXT, reviewed_at TEXT, source_repo TEXT, config TEXT DEFAULT '{}', permissions TEXT DEFAULT '[]', capabilities TEXT DEFAULT '{}', dependencies TEXT DEFAULT '{}', operator_egress INTEGER DEFAULT 0, updated_at TEXT, sort_order INTEGER DEFAULT 0); CREATE TABLE plugin_settings_fields (plugin_id TEXT, field_key TEXT, scope TEXT, secret INTEGER); CREATE TABLE plugin_error_log (id INTEGER PRIMARY KEY AUTOINCREMENT, plugin_id TEXT, level TEXT, message TEXT, ts TEXT DEFAULT '2026-01-01');`); diff --git a/server/tests/unit/plugins/provider-hook-grant.test.ts b/server/tests/unit/plugins/provider-hook-grant.test.ts index 57d1f25b2..90237baf0 100644 --- a/server/tests/unit/plugins/provider-hook-grant.test.ts +++ b/server/tests/unit/plugins/provider-hook-grant.test.ts @@ -49,6 +49,16 @@ describe('providersOf enforces the hook:* grant', () => { expect(s.providersOf('atlasLayerProvider')).toEqual(['atlas']); expect(s.providersOf('journalEntryProvider')).toEqual(['journal']); }); + + it('maps notificationChannel to hook:notification-channel', () => { + const s = makeSupervisor(); + put(s, 'gotify', 'active', ['notificationChannel'], ['hook:notification-channel']); + // Implements the hook but was never granted it — must not become a channel. + put(s, 'sneaky', 'active', ['notificationChannel'], ['notify:send', 'http:outbound']); + // Granted, but disabled — a channel must not survive being turned off. + put(s, 'off', 'stopped', ['notificationChannel'], ['hook:notification-channel']); + expect(s.providersOf('notificationChannel')).toEqual(['gotify']); + }); }); describe('runtime.invokeHook defense-in-depth', () => { diff --git a/server/tests/unit/plugins/settings-isolation.test.ts b/server/tests/unit/plugins/settings-isolation.test.ts new file mode 100644 index 000000000..83f862b22 --- /dev/null +++ b/server/tests/unit/plugins/settings-isolation.test.ts @@ -0,0 +1,135 @@ +/** + * Plugin settings isolation. + * + * A plugin's settings live in its OWN storage — instance config in `plugins.config`, + * per-user config in `plugin_user_config` keyed by (plugin_id, user_id). Plugin code + * never writes the core `settings` table, and every read helper is host-bound to one + * plugin id. These tests pin that, plus the settings-key constraint: an unconstrained + * key (`__proto__`, `constructor`) used to resolve off Object.prototype, so a REQUIRED + * field with such a name reported as configured for a user who had configured nothing — + * which for a notification channel meant being dispatched to everyone with no credentials. + */ +import { describe, it, expect, vi, beforeAll, beforeEach } from 'vitest'; + +const { testDb, dbMock } = vi.hoisted(() => { + const Database = require('better-sqlite3'); + const db = new Database(':memory:'); + return { testDb: db, dbMock: { db, closeDb: () => {}, reinitialize: () => {}, canAccessTrip: () => null } }; +}); +vi.mock('../../../src/db/database', () => dbMock); +vi.mock('../../../src/config', () => ({ JWT_SECRET: 'x'.repeat(40), ENCRYPTION_KEY: 'a'.repeat(64), updateJwtSecret: () => {} })); + +import { createTables } from '../../../src/db/schema'; +import { runMigrations } from '../../../src/db/migrations'; +import { createUser } from '../../helpers/factories'; +import { readUserSettingsDecrypted, hasRequiredUserSettings, readUserSettingDecrypted } from '../../../src/nest/plugins/plugins.service'; +import { parseManifest, ManifestError } from '../../../src/nest/plugins/install/manifest'; + +let uid: number; + +function declareField(pluginId: string, key: string, opts: { required?: boolean; secret?: boolean } = {}) { + testDb.prepare( + `INSERT INTO plugin_settings_fields (plugin_id, field_key, label, input_type, required, secret, scope, sort_order) + VALUES (?, ?, ?, 'text', ?, ?, 'user', 0)`, + ).run(pluginId, key, key, opts.required ? 1 : 0, opts.secret ? 1 : 0); +} +function setUserConfig(pluginId: string, config: Record) { + testDb.prepare('INSERT OR REPLACE INTO plugin_user_config (plugin_id, user_id, config) VALUES (?, ?, ?)').run(pluginId, uid, JSON.stringify(config)); +} + +beforeAll(() => { createTables(testDb); runMigrations(testDb); }); +beforeEach(() => { + testDb.prepare('DELETE FROM plugin_settings_fields').run(); + testDb.prepare('DELETE FROM plugin_user_config').run(); + testDb.prepare('DELETE FROM settings').run(); + testDb.prepare('DELETE FROM users').run(); + uid = createUser(testDb).user.id; +}); + +describe('plugin settings are isolated from core and from each other', () => { + it('PSET-001 — a plugin declaring "webhook_url" cannot touch the CORE settings row', () => { + testDb.prepare("INSERT INTO settings (user_id, key, value) VALUES (?, 'webhook_url', 'https://core.example.com/real')").run(uid); + declareField('evil', 'webhook_url'); + setUserConfig('evil', { webhook_url: 'https://attacker.example.com' }); + + // The user's REAL notification webhook is untouched — the plugin's value lives in + // its own blob, in its own table. The namespacing is structural, not by key naming. + const core = testDb.prepare("SELECT value FROM settings WHERE user_id = ? AND key = 'webhook_url'").get(uid) as { value: string }; + expect(core.value).toBe('https://core.example.com/real'); + expect(readUserSettingsDecrypted('evil', uid)).toEqual({ webhook_url: 'https://attacker.example.com' }); + }); + + it('PSET-002 — plugin A cannot read plugin B’s config, even with the same key name', () => { + declareField('a', 'token', { secret: true }); + declareField('b', 'token', { secret: true }); + setUserConfig('b', { token: 'B-SECRET' }); + + expect(readUserSettingsDecrypted('a', uid)).toEqual({}); + expect(readUserSettingDecrypted('a', uid, 'token')).toBeUndefined(); + expect(readUserSettingDecrypted('b', uid, 'token')).toBe('B-SECRET'); + }); + + it('PSET-003 — a plugin only ever sees its own DECLARED keys', () => { + declareField('p', 'declared'); + // An undeclared key that somehow reached the blob is not handed to the plugin. + setUserConfig('p', { declared: 'yes', sneaked: 'no' }); + expect(readUserSettingsDecrypted('p', uid)).toEqual({ declared: 'yes' }); + }); +}); + +describe('settings keys cannot resolve off the prototype chain', () => { + it.each(['__proto__', 'constructor', 'prototype'])( + 'PSET-004 — a REQUIRED field named "%s" is NOT reported as configured', + (key) => { + declareField('evil', key, { required: true }); + // The user has configured nothing at all. + expect(hasRequiredUserSettings('evil', uid)).toBe(false); + expect(readUserSettingsDecrypted('evil', uid)).toEqual({}); + }, + ); + + it('PSET-005 — a genuinely configured required field still reports configured', () => { + declareField('good', 'appToken', { required: true, secret: true }); + expect(hasRequiredUserSettings('good', uid)).toBe(false); + setUserConfig('good', { appToken: 'T' }); + expect(hasRequiredUserSettings('good', uid)).toBe(true); + }); + + it('PSET-006 — the manifest rejects such a key at install', () => { + const base = { id: 'evil', name: 'Evil', version: '1.0.0', apiVersion: 1, type: 'integration', nativeModules: false, permissions: [] }; + for (const key of ['__proto__', 'constructor', 'prototype', 'has space', '1leading', 'a'.repeat(65)]) { + expect(() => parseManifest({ ...base, settings: [{ key, scope: 'user' }] })).toThrow(ManifestError); + } + // …and still accepts an ordinary one. + expect(parseManifest({ ...base, settings: [{ key: 'appToken', scope: 'user', secret: true, required: true }] }).settings[0].key).toBe('appToken'); + }); +}); + +/** + * The channel label is a plugin-supplied string that becomes a column header in the + * user's notification preferences matrix, so the host bounds it like every other + * plugin string it renders (cf. cap()/stripEmoji in the calendar + photo controllers). + */ +describe('a plugin channel label is bounded by the host', () => { + it('PSET-007 — an oversized, emoji-laden capabilities.title is capped and stripped', async () => { + const { PluginRuntimeService } = await import('../../../src/nest/plugins/plugin-runtime.service'); + process.env.TREK_PLUGINS_ENABLED = 'true'; + + testDb.prepare( + `INSERT OR REPLACE INTO plugins (id, name, status, enabled, version, permissions, granted_permissions, capabilities, config) + VALUES ('loud', 'Loud', 'active', 1, '1.0.0', '[]', '[]', ?, '{}')`, + ).run(JSON.stringify({ notificationChannel: { title: '🎉'.repeat(5) + 'A'.repeat(500) } })); + + const rt = new PluginRuntimeService(); + // Stand the plugin up as a granted, active notificationChannel provider. + (rt as unknown as { supervisor: { running: Map } }).supervisor.running.set('loud', { + id: 'loud', status: 'active', hooks: ['notificationChannel'], granted: new Set(['hook:notification-channel']), + }); + + const [channel] = rt.notificationChannels(); + expect(channel.id).toBe('plugin:loud'); + expect(channel.label!.length).toBeLessThanOrEqual(40); + expect(channel.label).not.toMatch(/\p{Extended_Pictographic}/u); + delete process.env.TREK_PLUGINS_ENABLED; + }); +}); diff --git a/server/tests/unit/services/channelOverride.test.ts b/server/tests/unit/services/channelOverride.test.ts new file mode 100644 index 000000000..5044b0792 --- /dev/null +++ b/server/tests/unit/services/channelOverride.test.ts @@ -0,0 +1,193 @@ +/** + * A plugin channel may only ever ADD a channel — never override or shadow a built-in. + * + * The runtime mints plugin ids with pluginChannelId() and leaves the built-in-only + * privileges off, so none of this is reachable through a manifest. These tests pin the + * REGISTRY's own enforcement, so the guarantee survives a bug or a future caller that + * hands the registry a channel it shouldn't. Without it, a channel claiming `email` + * rides the user's email opt-in and receives admin-scoped notifications. + */ +import { describe, it, expect, vi, beforeAll, beforeEach } from 'vitest'; + +const { testDb, dbMock } = vi.hoisted(() => { + const Database = require('better-sqlite3'); + const db = new Database(':memory:'); + return { testDb: db, dbMock: { db, closeDb: () => {}, reinitialize: () => {}, canAccessTrip: () => null, isOwner: () => false, getPlaceWithTags: () => null } }; +}); +vi.mock('../../../src/db/database', () => dbMock); +vi.mock('../../../src/config', () => ({ JWT_SECRET: 'x'.repeat(40), ENCRYPTION_KEY: 'a'.repeat(64), updateJwtSecret: () => {} })); +vi.mock('../../../src/services/apiKeyCrypto', () => ({ decrypt_api_key: (v: string) => v, maybe_encrypt_api_key: (v: string) => v, encrypt_api_key: (v: string) => v })); +const { sendMailMock } = vi.hoisted(() => ({ sendMailMock: vi.fn().mockResolvedValue({ accepted: ['a@b.c'] }) })); +vi.mock('nodemailer', () => ({ default: { createTransport: vi.fn(() => ({ sendMail: sendMailMock, verify: vi.fn() })) } })); +vi.stubGlobal('fetch', vi.fn()); +vi.mock('../../../src/websocket', () => ({ broadcastToUser: vi.fn() })); +vi.mock('../../../src/utils/ssrfGuard', () => ({ checkSsrf: vi.fn(async () => ({ allowed: true, resolvedIp: '1.2.3.4' })), createPinnedDispatcher: vi.fn(() => ({})) })); + +import { createTables } from '../../../src/db/schema'; +import { runMigrations } from '../../../src/db/migrations'; +import { resetTestDb } from '../../helpers/test-db'; +import { createUser, createAdmin, setAppSetting, setNotificationChannels } from '../../helpers/factories'; +import { send } from '../../../src/services/notificationService'; +import { getPreferencesMatrix } from '../../../src/services/notificationPreferencesService'; +import { + setPluginChannelSource, + listChannels, + getChannel, + registerChannel, + type ExternalChannel, +} from '../../../src/services/notifications/channelRegistry'; + +beforeAll(() => { createTables(testDb); runMigrations(testDb); }); +beforeEach(() => { resetTestDb(testDb); setPluginChannelSource(null); }); + +const rogueSend = vi.fn().mockResolvedValue(true); +const rogueGlobal = vi.fn().mockResolvedValue(true); + +function setSmtp(): void { + setAppSetting(testDb, 'smtp_host', 'mail.test.com'); + setAppSetting(testDb, 'smtp_port', '587'); + setAppSetting(testDb, 'smtp_from', 'trek@test.com'); +} + +/** A channel claiming a BUILT-IN id plus the built-in-only privilege flags. */ +function rogue(over: Partial = {}): ExternalChannel { + return { + id: 'email', + source: 'builtin', + bypassesActiveToggleForAdminEvents: true, + supportsAdminGlobal: true, + supportsEvent: () => true, + isConfiguredFor: () => true, + sendToUser: rogueSend, + sendGlobal: rogueGlobal, + ...over, + } as ExternalChannel; +} + +const TRIP_INVITE = { event: 'trip_invite', actorId: null, scope: 'user', targetId: 0, params: { trip: 'Rome', actor: 'A', invitee: 'B', tripId: '1' } } as const; + +describe('a plugin channel can never override a built-in', () => { + beforeEach(() => { rogueSend.mockClear(); rogueGlobal.mockClear(); }); + + it('CHOVR-001 — a channel claiming a built-in id is dropped from the registry', () => { + setPluginChannelSource(() => [rogue()]); + expect(listChannels().filter(c => c.id === 'email')).toHaveLength(1); + expect(getChannel('email')!.source).toBe('builtin'); + }); + + it('CHOVR-002 — it does NOT ride the user’s email opt-in', async () => { + const { user } = createUser(testDb); + setSmtp(); + setNotificationChannels(testDb, 'email'); // the user enabled EMAIL, not a plugin + setPluginChannelSource(() => [rogue()]); + + await send({ ...TRIP_INVITE, targetId: user.id }); + + expect(rogueSend).not.toHaveBeenCalled(); + expect(sendMailMock).toHaveBeenCalledTimes(1); // the real email channel still delivers + }); + + it('CHOVR-003 — an un-namespaced id is dropped even when it collides with nothing', () => { + setPluginChannelSource(() => [rogue({ id: 'carrier-pigeon', source: 'plugin' })]); + expect(listChannels().map(c => c.id)).not.toContain('carrier-pigeon'); + }); + + it('CHOVR-004 — a properly namespaced channel IS admitted', () => { + setPluginChannelSource(() => [rogue({ id: 'plugin:gotify', source: 'plugin' })]); + expect(listChannels().map(c => c.id)).toContain('plugin:gotify'); + }); + + it('CHOVR-005 — an admitted plugin channel has its built-in-only privileges stripped', () => { + setPluginChannelSource(() => [rogue({ id: 'plugin:gotify' })]); + const ch = getChannel('plugin:gotify')!; + expect(ch.source).toBe('plugin'); // it cannot self-declare as a built-in + expect(ch.bypassesActiveToggleForAdminEvents).toBe(false); + expect(ch.supportsAdminGlobal).toBe(false); + expect(ch.sendGlobal).toBeUndefined(); + }); + + it('CHOVR-006 — a plugin channel gets NO admin-scoped event, even claiming to support one', async () => { + createAdmin(testDb); + setSmtp(); + setNotificationChannels(testDb, 'plugin:gotify'); + // supportsEvent lies and says yes to everything; the registry strips what makes it matter. + setPluginChannelSource(() => [rogue({ id: 'plugin:gotify', supportsEvent: () => true })]); + + await send({ event: 'version_available', actorId: null, scope: 'admin', targetId: 0, params: { version: '9.9.9' } }); + + expect(rogueSend).not.toHaveBeenCalled(); + expect(rogueGlobal).not.toHaveBeenCalled(); + }); + + it('CHOVR-007 — duplicate plugin ids collapse to one (no double send, no double column)', async () => { + const { user } = createUser(testDb); + setNotificationChannels(testDb, 'plugin:gotify'); + setPluginChannelSource(() => [rogue({ id: 'plugin:gotify' }), rogue({ id: 'plugin:gotify' })]); + + expect(listChannels().filter(c => c.id === 'plugin:gotify')).toHaveLength(1); + await send({ ...TRIP_INVITE, targetId: user.id }); + expect(rogueSend).toHaveBeenCalledTimes(1); + }); + + it('CHOVR-008 — the preferences matrix never shows a duplicate column', () => { + const { user } = createUser(testDb); + setNotificationChannels(testDb, 'email'); + setPluginChannelSource(() => [rogue()]); + const ids = getPreferencesMatrix(user.id, 'user').channels.map(c => c.id); + expect(ids.filter(i => i === 'email')).toHaveLength(1); + expect(new Set(ids).size).toBe(ids.length); + }); + + it('CHOVR-009 — a built-in may not claim the plugin namespace either', () => { + expect(() => registerChannel(rogue({ id: 'plugin:sneaky' }))).toThrow(/plugin namespace/); + }); +}); + +describe('a live plugin channel needs no second opt-in', () => { + beforeEach(() => { rogueSend.mockClear(); }); + + it('CHOVR-011 — it appears in the matrix with NOTHING in notification_channels', () => { + const { user } = createUser(testDb); + setNotificationChannels(testDb, 'none'); // the admin has enabled no built-in at all + setPluginChannelSource(() => [rogue({ id: 'plugin:gotify', source: 'plugin', label: 'Gotify' })]); + + const matrix = getPreferencesMatrix(user.id, 'user'); + const ch = matrix.channels.find(c => c.id === 'plugin:gotify')!; + // Enabling the PLUGIN is the opt-in. There is no UI that can write a `plugin:` id into + // the notification_channels CSV, so requiring one meant the channel could never show. + expect(ch.active).toBe(true); + expect(ch.label).toBe('Gotify'); + expect(matrix.implemented_combos['trip_invite']).toContain('plugin:gotify'); + }); + + it('CHOVR-012 — and it actually DELIVERS with nothing in notification_channels', async () => { + const { user } = createUser(testDb); + setNotificationChannels(testDb, 'none'); + setPluginChannelSource(() => [rogue({ id: 'plugin:gotify', source: 'plugin' })]); + + await send({ ...TRIP_INVITE, targetId: user.id }); + expect(rogueSend).toHaveBeenCalledTimes(1); + }); + + it('CHOVR-013 — a BUILT-IN still needs its explicit switch', async () => { + const { user } = createUser(testDb); + setSmtp(); + setNotificationChannels(testDb, 'none'); // email NOT enabled + sendMailMock.mockClear(); + + await send({ ...TRIP_INVITE, targetId: user.id }); + expect(sendMailMock).not.toHaveBeenCalled(); + }); + + it('CHOVR-014 — an unconfigured user sees the column but gets no delivery', async () => { + const { user } = createUser(testDb); + setPluginChannelSource(() => [rogue({ id: 'plugin:gotify', source: 'plugin', isConfiguredFor: () => false })]); + + const ch = getPreferencesMatrix(user.id, 'user').channels.find(c => c.id === 'plugin:gotify')!; + expect(ch.active).toBe(true); // shown — so they can see it and go configure it + expect(ch.configured).toBe(false); // …but flagged as needing setup + + await send({ ...TRIP_INVITE, targetId: user.id }); + expect(rogueSend).not.toHaveBeenCalled(); // no credentials → nothing to send with + }); +}); diff --git a/server/tests/unit/services/channelRegistry.test.ts b/server/tests/unit/services/channelRegistry.test.ts new file mode 100644 index 000000000..636e211ab --- /dev/null +++ b/server/tests/unit/services/channelRegistry.test.ts @@ -0,0 +1,115 @@ +/** + * Unit tests for the external notification channel registry. + * Covers CHREG-001 to CHREG-008. + */ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { + listChannels, + getChannel, + registerChannel, + setPluginChannelSource, + pluginChannelId, + isPluginChannelId, + __resetChannelsForTest, + type ChannelMessage, + type ExternalChannel, +} from '../../../src/services/notifications/channelRegistry'; +import { registerBuiltinChannels } from '../../../src/services/notifications/builtins'; + +function fakeChannel(id: string, over: Partial = {}): ExternalChannel { + return { + id, + source: 'plugin', + label: id, + supportsEvent: () => true, + isConfiguredFor: () => true, + sendToUser: async () => true, + ...over, + }; +} + +const MSG: ChannelMessage = { event: 'trip_invite', title: 't', body: 'b' }; + +beforeEach(() => { + __resetChannelsForTest(); + registerBuiltinChannels(); +}); + +afterEach(() => { + setPluginChannelSource(null); +}); + +describe('channelRegistry', () => { + it('CHREG-001 — the three built-in external channels are registered; in-app is not', () => { + expect(listChannels().map(c => c.id)).toEqual(['email', 'webhook', 'ntfy']); + expect(getChannel('inapp')).toBeUndefined(); + }); + + it('CHREG-002 — plugin channels come from the injected source and are namespaced', () => { + setPluginChannelSource(() => [fakeChannel(pluginChannelId('gotify'))]); + expect(listChannels().map(c => c.id)).toContain('plugin:gotify'); + expect(getChannel('plugin:gotify')?.source).toBe('plugin'); + expect(isPluginChannelId('plugin:gotify')).toBe(true); + expect(isPluginChannelId('email')).toBe(false); + }); + + it('CHREG-003 — a plugin channel disappears when the runtime stops reporting it', () => { + let live = true; + setPluginChannelSource(() => (live ? [fakeChannel('plugin:gotify')] : [])); + expect(getChannel('plugin:gotify')).toBeDefined(); + live = false; + expect(getChannel('plugin:gotify')).toBeUndefined(); + expect(listChannels().map(c => c.id)).toEqual(['email', 'webhook', 'ntfy']); + }); + + it('CHREG-004 — a throwing plugin source cannot take notifications down', () => { + setPluginChannelSource(() => { + throw new Error('runtime exploded'); + }); + expect(listChannels().map(c => c.id)).toEqual(['email', 'webhook', 'ntfy']); + }); + + it('CHREG-005 — a plugin can never claim a built-in id', () => { + // The prefix is the whole defence: an id without it is not a plugin channel id, + // and pluginChannelId() is the only way the runtime mints one. + expect(pluginChannelId('email')).toBe('plugin:email'); + expect(getChannel('email')?.source).toBe('builtin'); + }); + + it('CHREG-006 — only email declares the admin-scoped toggle bypass', () => { + expect(getChannel('email')?.bypassesActiveToggleForAdminEvents).toBe(true); + expect(getChannel('webhook')?.bypassesActiveToggleForAdminEvents).toBeUndefined(); + expect(getChannel('ntfy')?.bypassesActiveToggleForAdminEvents).toBeUndefined(); + }); + + it('CHREG-007 — webhook and ntfy deliver the admin-global copy; email does not', () => { + expect(getChannel('webhook')?.supportsAdminGlobal).toBe(true); + expect(getChannel('ntfy')?.supportsAdminGlobal).toBe(true); + expect(getChannel('email')?.supportsAdminGlobal).toBeUndefined(); + }); + + it('CHREG-008 — built-ins carry every event except synology_session_cleared', () => { + for (const id of ['email', 'webhook', 'ntfy']) { + expect(getChannel(id)!.supportsEvent('trip_invite')).toBe(true); + expect(getChannel(id)!.supportsEvent('version_available')).toBe(true); + expect(getChannel(id)!.supportsEvent('synology_session_cleared')).toBe(false); + } + }); + + it('CHREG-009 — registerChannel replaces an existing id rather than duplicating it', () => { + registerChannel(fakeChannel('email', { source: 'builtin' })); + expect(listChannels().filter(c => c.id === 'email')).toHaveLength(1); + }); + + it('CHREG-010 — a channel that rejects is the caller’s problem, not the registry’s', async () => { + const boom = fakeChannel('plugin:boom', { + sendToUser: async () => { + throw new Error('nope'); + }, + }); + setPluginChannelSource(() => [boom]); + await expect(getChannel('plugin:boom')!.sendToUser(1, MSG)).rejects.toThrow('nope'); + // and the registry is unharmed + expect(listChannels()).toHaveLength(4); + }); +}); diff --git a/server/tests/unit/services/notificationPreferencesService.test.ts b/server/tests/unit/services/notificationPreferencesService.test.ts index b619552c0..ce5771bea 100644 --- a/server/tests/unit/services/notificationPreferencesService.test.ts +++ b/server/tests/unit/services/notificationPreferencesService.test.ts @@ -43,7 +43,7 @@ import { setAdminPreferences, getAdminGlobalPref, getActiveChannels, - getAvailableChannels, + isSmtpConfigured, isWebhookConfigured, } from '../../../src/services/notificationPreferencesService'; @@ -134,24 +134,24 @@ describe('getPreferencesMatrix', () => { expect(preferences['booking_change']!['email']).toBe(true); }); - it('NPREF-008 — available_channels.inapp is always true', () => { + it('NPREF-008 — the inapp channel is always active', () => { const { user } = createUser(testDb); - const { available_channels } = getPreferencesMatrix(user.id, 'user'); - expect(available_channels.inapp).toBe(true); + const { channels } = getPreferencesMatrix(user.id, 'user'); + expect(channels.find(c => c.id === 'inapp')?.active).toBe(true); }); - it('NPREF-009 — available_channels.email is true when email is in notification_channels', () => { + it('NPREF-009 — email is active when email is in notification_channels', () => { const { user } = createUser(testDb); setNotificationChannels(testDb, 'email'); - const { available_channels } = getPreferencesMatrix(user.id, 'user'); - expect(available_channels.email).toBe(true); + const { channels } = getPreferencesMatrix(user.id, 'user'); + expect(channels.find(c => c.id === 'email')?.active).toBe(true); }); - it('NPREF-010 — available_channels.email is false when email is not in notification_channels', () => { + it('NPREF-010 — email is inactive when email is not in notification_channels', () => { const { user } = createUser(testDb); // No notification_channels set → defaults to none - const { available_channels } = getPreferencesMatrix(user.id, 'user'); - expect(available_channels.email).toBe(false); + const { channels } = getPreferencesMatrix(user.id, 'user'); + expect(channels.find(c => c.id === 'email')?.active).toBe(false); }); it('NPREF-011 — implemented_combos maps version_available to [inapp, email, webhook, ntfy]', () => { @@ -241,34 +241,36 @@ describe('getActiveChannels', () => { }); // ───────────────────────────────────────────────────────────────────────────── -// getAvailableChannels +// SMTP / active-channel detection // ───────────────────────────────────────────────────────────────────────────── -describe('getAvailableChannels', () => { +describe('channel availability', () => { it('NPREF-019 — detects SMTP config from app_settings.smtp_host', () => { setAppSetting(testDb, 'smtp_host', 'mail.example.com'); - const channels = getAvailableChannels(); - expect(channels.email).toBe(true); - expect(channels.inapp).toBe(true); + expect(isSmtpConfigured()).toBe(true); }); - it('NPREF-020 — webhook available when admin has enabled the webhook channel', () => { + it('NPREF-020 — webhook is active when admin has enabled the webhook channel', () => { setNotificationChannels(testDb, 'webhook'); - const channels = getAvailableChannels(); - expect(channels.webhook).toBe(true); + expect(getActiveChannels()).toContain('webhook'); }); it('NPREF-021 — detects SMTP config from env var SMTP_HOST', () => { const original = process.env.SMTP_HOST; process.env.SMTP_HOST = 'env-mail.example.com'; try { - const channels = getAvailableChannels(); - expect(channels.email).toBe(true); + expect(isSmtpConfigured()).toBe(true); } finally { if (original === undefined) delete process.env.SMTP_HOST; else process.env.SMTP_HOST = original; } }); + + it('NPREF-022 — an unknown channel id in notification_channels is ignored', () => { + // e.g. a plugin channel left in the CSV after the plugin was uninstalled + setNotificationChannels(testDb, 'webhook,plugin:long-gone'); + expect(getActiveChannels()).toEqual(['webhook']); + }); }); // ───────────────────────────────────────────────────────────────────────────── diff --git a/server/tests/unit/services/notificationService.test.ts b/server/tests/unit/services/notificationService.test.ts index de2e4e896..2a7b45260 100644 --- a/server/tests/unit/services/notificationService.test.ts +++ b/server/tests/unit/services/notificationService.test.ts @@ -2,7 +2,7 @@ * Unit tests for the unified notificationService.send(). * Covers NSVC-001 to NSVC-014. */ -import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest'; +import { describe, it, expect, vi, beforeAll, beforeEach, afterEach, afterAll } from 'vitest'; const { testDb, dbMock } = vi.hoisted(() => { const Database = require('better-sqlite3'); @@ -59,6 +59,7 @@ import { runMigrations } from '../../../src/db/migrations'; import { resetTestDb } from '../../helpers/test-db'; import { createUser, createAdmin, setAppSetting, setNotificationChannels, disableNotificationPref } from '../../helpers/factories'; import { send } from '../../../src/services/notificationService'; +import { setPluginChannelSource, type ExternalChannel } from '../../../src/services/notifications/channelRegistry'; // ── Helpers ──────────────────────────────────────────────────────────────── @@ -549,3 +550,117 @@ describe('send() — ntfy channel dispatch', () => { expect(ntfyCalls[0][1].headers['Tags']).toContain('package'); }); }); + +// ───────────────────────────────────────────────────────────────────────────── +// Plugin notification channels (hook:notification-channel) +// ───────────────────────────────────────────────────────────────────────────── + +describe('send() — plugin notification channels', () => { + const sendSpy = vi.fn(); + + function installPluginChannel(over: Partial = {}): void { + sendSpy.mockClear(); + sendSpy.mockResolvedValue(undefined); + setPluginChannelSource(() => [ + { + id: 'plugin:gotify', + source: 'plugin', + label: 'Gotify', + supportsEvent: (e: string) => e !== 'version_available' && e !== 'synology_session_cleared', + isConfiguredFor: () => true, + sendToUser: sendSpy, + ...over, + } as ExternalChannel, + ]); + } + + afterEach(() => setPluginChannelSource(null)); + + it('NSVC-PLUG-001 — delivers to a plugin channel the admin enabled', async () => { + const { user } = createUser(testDb); + installPluginChannel(); + setNotificationChannels(testDb, 'plugin:gotify'); + + await send({ event: 'trip_invite', actorId: null, scope: 'user', targetId: user.id, params: { trip: 'Rome', actor: 'Alice', invitee: 'Bob', tripId: '1' } }); + + expect(sendSpy).toHaveBeenCalledTimes(1); + const [recipientId, msg] = sendSpy.mock.calls[0]; + expect(recipientId).toBe(user.id); + // The host renders the text — a channel plugin never touches i18n. + expect(msg.event).toBe('trip_invite'); + expect(msg.title).toBeTruthy(); + expect(msg.body).toContain('Rome'); + }); + + it('NSVC-PLUG-002 — delivers with NOTHING in notification_channels: enabling the plugin IS the opt-in', async () => { + const { user } = createUser(testDb); + installPluginChannel(); + setNotificationChannels(testDb, 'none'); + + await send({ event: 'trip_invite', actorId: null, scope: 'user', targetId: user.id, params: { trip: 'Rome', actor: 'Alice', invitee: 'Bob', tripId: '1' } }); + + // A built-in always exists in the code, so it needs an explicit switch. A plugin + // channel only exists because an admin installed and enabled that plugin — and + // nothing can write a `plugin:` id into this CSV anyway, so requiring a second + // opt-in meant the channel could never be turned on at all. + expect(sendSpy).toHaveBeenCalledTimes(1); + }); + + it('NSVC-PLUG-003 — skipped when the user opted out of this event on it', async () => { + const { user } = createUser(testDb); + installPluginChannel(); + setNotificationChannels(testDb, 'plugin:gotify'); + disableNotificationPref(testDb, user.id, 'trip_invite', 'plugin:gotify'); + + await send({ event: 'trip_invite', actorId: null, scope: 'user', targetId: user.id, params: { trip: 'Rome', actor: 'Alice', invitee: 'Bob', tripId: '1' } }); + + expect(sendSpy).not.toHaveBeenCalled(); + }); + + it('NSVC-PLUG-004 — skipped when the user has not set their credentials', async () => { + const { user } = createUser(testDb); + installPluginChannel({ isConfiguredFor: () => false }); + setNotificationChannels(testDb, 'plugin:gotify'); + + await send({ event: 'trip_invite', actorId: null, scope: 'user', targetId: user.id, params: { trip: 'Rome', actor: 'Alice', invitee: 'Bob', tripId: '1' } }); + + expect(sendSpy).not.toHaveBeenCalled(); + }); + + it('NSVC-PLUG-005 — a throwing plugin channel does not stop in-app or email delivery', async () => { + const { user } = createUser(testDb); + installPluginChannel({ sendToUser: vi.fn().mockRejectedValue(new Error('gotify is down')) }); + setSmtp(); + setNotificationChannels(testDb, 'email,plugin:gotify'); + sendMailMock.mockClear(); + + await send({ event: 'trip_invite', actorId: null, scope: 'user', targetId: user.id, params: { trip: 'Rome', actor: 'Alice', invitee: 'Bob', tripId: '1' } }); + + expect(sendMailMock).toHaveBeenCalledTimes(1); + expect(getInAppNotifications(user.id).length).toBe(1); + }); + + it('NSVC-PLUG-006 — never receives an admin-scoped event', async () => { + createAdmin(testDb); + // Even if a (malicious) channel claims to support it, ADMIN_SCOPED_EVENTS is + // gated host-side: only a channel that bypasses the toggle (email) delivers those. + installPluginChannel({ supportsEvent: () => true }); + setNotificationChannels(testDb, 'plugin:gotify'); + + await send({ event: 'version_available', actorId: null, scope: 'admin', targetId: 0, params: { version: '3.0.0' } }); + + expect(sendSpy).not.toHaveBeenCalled(); + }); + + it('NSVC-PLUG-007 — a channel that narrows its events only gets those', async () => { + const { user } = createUser(testDb); + installPluginChannel({ supportsEvent: (e: string) => e === 'booking_change' }); + setNotificationChannels(testDb, 'plugin:gotify'); + + await send({ event: 'trip_invite', actorId: null, scope: 'user', targetId: user.id, params: { trip: 'Rome', actor: 'Alice', invitee: 'Bob', tripId: '1' } }); + expect(sendSpy).not.toHaveBeenCalled(); + + await send({ event: 'booking_change', actorId: null, scope: 'user', targetId: user.id, params: { trip: 'Rome', actor: 'Alice', tripId: '1' } }); + expect(sendSpy).toHaveBeenCalledTimes(1); + }); +}); diff --git a/shared/src/i18n/ar/admin.ts b/shared/src/i18n/ar/admin.ts index d3d2dfd3b..91f6d207c 100644 --- a/shared/src/i18n/ar/admin.ts +++ b/shared/src/i18n/ar/admin.ts @@ -204,6 +204,15 @@ const admin: TranslationStrings = { 'admin.plugins.deactivated': 'تم تعطيل الإضافة', 'admin.plugins.viewErrors': 'عرض سجل الأخطاء', 'admin.plugins.errorLog': 'سجل الأخطاء', + 'admin.plugins.allowedHosts': 'المضيفون المسموح بهم', + 'admin.plugins.allowedHosts.hint': 'تتصل هذه الإضافة بخدمة لا يمكن لأحد سواك تسميتها (خادم مستضاف ذاتيًا). أضف المضيفين الذين يمكنها الوصول إليهم — ولن تصل إلى غيرهم.', + 'admin.plugins.allowedHosts.none': 'لم تتم إضافة أي مضيف بعد.', + 'admin.plugins.allowedHosts.unsupported': 'لا تستخدم هذه الإضافة مضيفين يحددهم المشغّل. مضيفوها المسموح بهم ثابتون في ملف التعريف.', + 'admin.plugins.allowedHosts.restartNote': 'الحفظ يعيد تشغيل الإضافة لتأخذ القائمة الجديدة.', + 'admin.plugins.allowedHosts.add': 'إضافة مضيف مسموح به', + 'admin.plugins.allowedHosts.count': '{n} مضيف مسموح به', + 'admin.plugins.operatorEgressPill': '+ مضيفون تضيفهم', + 'admin.plugins.operatorEgressHint': 'تتصل هذه الإضافة بخدمة لا يمكن لأحد سواك تسميتها (خادم مستضاف ذاتيًا). بعد التثبيت أضف المضيفين من ⋯ ← المضيفون المسموح بهم. ولن تصل إلى غيرهم.', 'admin.plugins.noErrors': 'لا توجد أخطاء مسجّلة.', 'admin.plugins.uninstalled': 'تمت إزالة الإضافة', 'admin.plugins.uninstallTitle': 'إزالة الإضافة؟', @@ -255,33 +264,49 @@ const admin: TranslationStrings = { 'قراءة معلومات الملف الشخصي الأساسية (الاسم والصورة الرمزية — وليس بيانات الاعتماد أبدًا)', 'admin.plugins.perm.db:read:costs': 'قراءة المصروفات (بنود الميزانية) التي يمكن للمستخدم الحالي الوصول إليها', 'admin.plugins.perm.db:read:packing': 'قراءة قوائم الأمتعة للرحلات التي يمكن للمستخدم الحالي الوصول إليها', - 'admin.plugins.perm.db:write:packing': 'إنشاء عناصر قائمة الأمتعة وتعديلها وحذفها في الرحلات التي يمكن للمستخدم الحالي تعديلها', + 'admin.plugins.perm.db:write:packing': + 'إنشاء عناصر قائمة الأمتعة وتعديلها وحذفها في الرحلات التي يمكن للمستخدم الحالي تعديلها', 'admin.plugins.perm.weather:read': 'قراءة توقعات الطقس المخزنة مؤقتًا للمضيف (حسب الإحداثيات)', 'admin.plugins.perm.db:read:categories': 'قراءة قائمة فئات الأماكن العامة', 'admin.plugins.perm.db:read:tags': 'قراءة وسوم المستخدم الحالي', 'admin.plugins.perm.db:write:tags': 'إنشاء وسوم المستخدم الحالي وتعديلها وحذفها', 'admin.plugins.perm.db:read:todos': 'قراءة مهام الرحلات التي يمكن للمستخدم الحالي الوصول إليها', 'admin.plugins.perm.db:write:todos': 'إنشاء مهام وتعديلها وحذفها في الرحلات التي يمكن للمستخدم الحالي تعديلها', - 'admin.plugins.perm.db:write:atlas': 'وضع علامة على البلدان والمناطق كمُزارة وإدارة قائمة أمنيات المستخدم الحالي (يتطلب إضافة Atlas)', - 'admin.plugins.perm.db:write:vacay': 'تبديل أيام الإجازة وعطلات الشركة في الخطة النشطة للمستخدم الحالي (يتطلب إضافة Vacay)', - 'admin.plugins.perm.db:write:journal': 'إنشاء إدخالات اليوميات وتعديلها وحذفها في اليوميات التي يمكن للمستخدم الحالي تعديلها (يتطلب إضافة Journey)', - 'admin.plugins.perm.db:write:collections': 'إنشاء المجموعات وتعديلها وحفظ الأماكن فيها، بدور المجموعة الخاص بالمستخدم الحالي (يتطلب إضافة Collections)', - 'admin.plugins.perm.db:write:files': 'إرفاق ملفات بالرحلات التي يمكن للمستخدم الحالي تعديلها (حد 10 ميغابايت، الامتدادات المحظورة مرفوضة) وإدارة روابطها', - 'admin.plugins.perm.db:write:collab': 'نشر ملاحظات واستطلاعات ورسائل دردشة في الرحلات التي يمكن للمستخدم الحالي تعديلها (يتطلب إضافة Collab)', - 'admin.plugins.perm.db:write:members': 'إضافة مستخدمين إلى الرحلات — يمنح الوصول إلى الرحلة؛ محمي بحق إدارة الأعضاء الخاص بالمستخدم الحالي', - 'admin.plugins.perm.notify:send': 'إرسال إشعار (صندوق الجرس + بريد إلكتروني/ntfy) إلى المستخدم الحالي أو رحلة ينتمي إليها — لا إلى مستلم عشوائي أبدًا', - 'admin.plugins.perm.ai:invoke': 'تشغيل مزود الذكاء الاصطناعي الذي أعده المسؤول (إكمال النص + استخراج المستندات) نيابة عن المستخدم الحالي — لا تحتفظ الإضافة بمفتاح أبدًا', - 'admin.plugins.perm.oauth:client': 'الاتصال بخدمة خارجية نيابة عنك عبر OAuth يديره المضيف (يحتفظ المضيف بالرموز، ولا تراها الإضافة أبدًا)', + 'admin.plugins.perm.db:write:atlas': + 'وضع علامة على البلدان والمناطق كمُزارة وإدارة قائمة أمنيات المستخدم الحالي (يتطلب إضافة Atlas)', + 'admin.plugins.perm.db:write:vacay': + 'تبديل أيام الإجازة وعطلات الشركة في الخطة النشطة للمستخدم الحالي (يتطلب إضافة Vacay)', + 'admin.plugins.perm.db:write:journal': + 'إنشاء إدخالات اليوميات وتعديلها وحذفها في اليوميات التي يمكن للمستخدم الحالي تعديلها (يتطلب إضافة Journey)', + 'admin.plugins.perm.db:write:collections': + 'إنشاء المجموعات وتعديلها وحفظ الأماكن فيها، بدور المجموعة الخاص بالمستخدم الحالي (يتطلب إضافة Collections)', + 'admin.plugins.perm.db:write:files': + 'إرفاق ملفات بالرحلات التي يمكن للمستخدم الحالي تعديلها (حد 10 ميغابايت، الامتدادات المحظورة مرفوضة) وإدارة روابطها', + 'admin.plugins.perm.db:write:collab': + 'نشر ملاحظات واستطلاعات ورسائل دردشة في الرحلات التي يمكن للمستخدم الحالي تعديلها (يتطلب إضافة Collab)', + 'admin.plugins.perm.db:write:members': + 'إضافة مستخدمين إلى الرحلات — يمنح الوصول إلى الرحلة؛ محمي بحق إدارة الأعضاء الخاص بالمستخدم الحالي', + 'admin.plugins.perm.notify:send': + 'إرسال إشعار (صندوق الجرس + بريد إلكتروني/ntfy) إلى المستخدم الحالي أو رحلة ينتمي إليها — لا إلى مستلم عشوائي أبدًا', + 'admin.plugins.perm.ai:invoke': + 'تشغيل مزود الذكاء الاصطناعي الذي أعده المسؤول (إكمال النص + استخراج المستندات) نيابة عن المستخدم الحالي — لا تحتفظ الإضافة بمفتاح أبدًا', + 'admin.plugins.perm.oauth:client': + 'الاتصال بخدمة خارجية نيابة عنك عبر OAuth يديره المضيف (يحتفظ المضيف بالرموز، ولا تراها الإضافة أبدًا)', 'admin.plugins.perm.db:read:files': 'قراءة ملفات الرحلات التي يمكن للمستخدم الحالي الوصول إليها', - 'admin.plugins.perm.db:write:reservations': 'إنشاء الحجوزات وتعديلها وحذفها في الرحلات التي يمكن للمستخدم الحالي تعديلها', - 'admin.plugins.perm.db:write:accommodations': 'إنشاء أماكن الإقامة وتعديلها وحذفها في الرحلات التي يمكن للمستخدم الحالي تعديلها', + 'admin.plugins.perm.db:write:reservations': + 'إنشاء الحجوزات وتعديلها وحذفها في الرحلات التي يمكن للمستخدم الحالي تعديلها', + 'admin.plugins.perm.db:write:accommodations': + 'إنشاء أماكن الإقامة وتعديلها وحذفها في الرحلات التي يمكن للمستخدم الحالي تعديلها', 'admin.plugins.perm.db:read:journal': 'قراءة يوميات السفر الخاصة بالمستخدم الحالي (يتطلب إضافة Journey)', 'admin.plugins.perm.db:read:atlas': 'قراءة البلدان والمناطق التي زارها المستخدم الحالي (يتطلب إضافة Atlas)', 'admin.plugins.perm.db:read:vacay': 'قراءة خطة الإجازة الخاصة بالمستخدم الحالي (يتطلب إضافة Vacay)', 'admin.plugins.perm.db:read:daynotes': 'قراءة ملاحظات اليوم للرحلات التي يمكن للمستخدم الحالي الوصول إليها', - 'admin.plugins.perm.db:read:collections': 'قراءة مجموعات الأماكن المحفوظة الخاصة بالمستخدم الحالي (يتطلب إضافة Collections)', - 'admin.plugins.perm.db:write:daynotes': 'إنشاء ملاحظات اليوم وتعديلها وحذفها في الرحلات التي يمكن للمستخدم الحالي تعديلها', - 'admin.plugins.perm.jobs:run': 'تشغيل مهام الخلفية المُعلنة وفق جدول زمني (بدون سياق مستخدم — لا يمكنه قراءة بيانات المستخدم)', + 'admin.plugins.perm.db:read:collections': + 'قراءة مجموعات الأماكن المحفوظة الخاصة بالمستخدم الحالي (يتطلب إضافة Collections)', + 'admin.plugins.perm.db:write:daynotes': + 'إنشاء ملاحظات اليوم وتعديلها وحذفها في الرحلات التي يمكن للمستخدم الحالي تعديلها', + 'admin.plugins.perm.jobs:run': + 'تشغيل مهام الخلفية المُعلنة وفق جدول زمني (بدون سياق مستخدم — لا يمكنه قراءة بيانات المستخدم)', 'admin.plugins.perm.db:write:costs': 'إنشاء مصروفات (بنود الميزانية) على الرحلات التي يمكن للمستخدم الحالي تعديلها', 'admin.plugins.perm.db:write:places': 'إضافة الأماكن وتعديلها وحذفها في الرحلات التي يمكن للمستخدم الحالي تعديلها', 'admin.plugins.perm.db:write:days': 'إضافة الأيام وتعديلها وحذفها في الرحلات التي يمكن للمستخدم الحالي تعديلها', @@ -301,12 +326,16 @@ const admin: TranslationStrings = { 'admin.plugins.perm.hook:atlas-layer-provider': 'إبراز دول على خريطة أطلس العالمية (مثل قوائم الأمنيات أو تحذيرات السفر)', 'admin.plugins.perm.hook:journal-entry-provider': 'إضافة صفوف إضافية (روابط، إحصاءات) إلى مدخلات اليوميات', - 'admin.plugins.perm.hook:user-data': 'محو أو تصدير البيانات التي يخزّنها عن مستخدم (اللائحة العامة لحماية البيانات: حذف الحساب وطلبات البيانات)', + 'admin.plugins.perm.hook:user-data': + 'محو أو تصدير البيانات التي يخزّنها عن مستخدم (اللائحة العامة لحماية البيانات: حذف الحساب وطلبات البيانات)', 'admin.plugins.perm.hook:trip-card-provider': 'إضافة شارات صغيرة (الحالة، الأعداد) إلى بطاقات الرحلات في لوحة التحكم', + 'admin.plugins.perm.hook:notification-channel': 'تسليم إشعاراتك عبر قناة إضافية', 'admin.plugins.perm.events:subscribe': 'التفاعل مع أحداث النشاط الأساسية (اسم الحدث والرحلة فقط، دون المحتوى مطلقًا)', 'admin.plugins.perm.http:outbound': 'إجراء طلبات صادرة إلى المضيفات المُعلنة الخاصة بها', - 'admin.plugins.perm.db:read:collab': 'قراءة الملاحظات والاستطلاعات ورسائل الدردشة في الرحلات التي يمكن للمستخدم الحالي الوصول إليها (يتطلب إضافة Collab)', - 'admin.plugins.perm.db:read:files:content': 'قراءة محتوى (بايتات) ملفات الرحلات التي يمكن للمستخدم الحالي الوصول إليها', + 'admin.plugins.perm.db:read:collab': + 'قراءة الملاحظات والاستطلاعات ورسائل الدردشة في الرحلات التي يمكن للمستخدم الحالي الوصول إليها (يتطلب إضافة Collab)', + 'admin.plugins.perm.db:read:files:content': + 'قراءة محتوى (بايتات) ملفات الرحلات التي يمكن للمستخدم الحالي الوصول إليها', 'admin.plugins.perm.db:create:trips': 'إنشاء رحلات جديدة يملكها المستخدم الحالي', 'admin.plugins.perm.rates:read': 'قراءة أسعار صرف العملات المخزنة مؤقتًا للمضيف', 'admin.plugins.updateConsentTitle': 'يتطلب هذا التحديث أذونات جديدة', @@ -376,6 +405,7 @@ const admin: TranslationStrings = { 'admin.plugins.cap.reservationSlot': 'تفاصيل الحجز', 'admin.plugins.cap.replacesTabs': 'يستبدل تبويبات المخطط', 'admin.plugins.cap.realtime': 'تحديثات فورية', + 'admin.plugins.cap.notificationChannel': 'قناة الإشعارات', 'admin.plugins.cap.photos': 'يوفّر الصور', 'admin.plugins.cap.calendar': 'يوفّر أحداث التقويم', 'admin.plugins.cap.placeDetails': 'يُثري الأماكن', diff --git a/shared/src/i18n/ar/settings.ts b/shared/src/i18n/ar/settings.ts index 3031f6cbf..c240e9112 100644 --- a/shared/src/i18n/ar/settings.ts +++ b/shared/src/i18n/ar/settings.ts @@ -270,6 +270,14 @@ const settings: TranslationStrings = { 'settings.notificationPreferences.email': 'Email', // en-fallback 'settings.notificationPreferences.webhook': 'Webhook', // en-fallback 'settings.notificationPreferences.inapp': 'In-App', // en-fallback + 'settings.notificationPreferences.notConfigured': 'لم يتم الإعداد بعد — قم بإعداده من إعدادات الإضافة', + 'settings.plugins.actions': 'الإجراءات', + 'settings.plugins.actions.confirm': 'تنفيذ هذا الإجراء؟', + 'settings.notificationPreferences.sendTest': 'إرسال اختبار', + 'settings.notificationPreferences.configure': 'تهيئة', + 'settings.notificationPreferences.testSuccess': 'تم إرسال إشعار اختباري.', + 'settings.notificationPreferences.testFailed': 'فشل الاختبار.', + 'settings.notificationPreferences.pluginConfigured': 'تم الإعداد. أدر بيانات الاعتماد من صفحة إعدادات الإضافة.', 'settings.notificationPreferences.ntfy': 'Ntfy', // en-fallback 'settings.webhookUrl.placeholder': 'https://discord.com/api/webhooks/...', // en-fallback 'settings.ntfyUrl.topicPlaceholder': 'my-trek-alerts', // en-fallback diff --git a/shared/src/i18n/br/admin.ts b/shared/src/i18n/br/admin.ts index 698625e79..efe12d611 100644 --- a/shared/src/i18n/br/admin.ts +++ b/shared/src/i18n/br/admin.ts @@ -166,7 +166,8 @@ const admin: TranslationStrings = { 'admin.plugins.uploaded': 'Plugin “{name}” enviado — ative para executar', 'admin.plugins.sideloaded': 'Carregado manualmente', 'admin.plugins.devLinkBadge': 'Dev-Link', - 'admin.plugins.devLinkHint': 'Carregado de um diretório de build local e recarregado a quente com dados reais — apenas para desenvolvimento', + 'admin.plugins.devLinkHint': + 'Carregado de um diretório de build local e recarregado a quente com dados reais — apenas para desenvolvimento', 'admin.plugins.devLinkTitle': 'Vincular um plugin local', 'admin.plugins.devLinkPathPlaceholder': '/absolute/path/to/your/plugin', 'admin.plugins.devLinkButton': 'Vincular', @@ -183,6 +184,15 @@ const admin: TranslationStrings = { 'admin.plugins.deactivated': 'Plugin desativado', 'admin.plugins.viewErrors': 'Ver registro de erros', 'admin.plugins.errorLog': 'Registro de erros', + 'admin.plugins.allowedHosts': 'Hosts permitidos', + 'admin.plugins.allowedHosts.hint': 'Este plugin fala com um serviço que só você pode nomear (um servidor auto-hospedado). Adicione os hosts que ele pode alcançar — nenhum outro.', + 'admin.plugins.allowedHosts.none': 'Nenhum host adicionado ainda.', + 'admin.plugins.allowedHosts.unsupported': 'Este plugin não usa hosts fornecidos pelo operador. Seus hosts permitidos são fixos no manifesto.', + 'admin.plugins.allowedHosts.restartNote': 'Salvar reinicia o plugin para que ele use a nova lista.', + 'admin.plugins.allowedHosts.add': 'Adicionar host permitido', + 'admin.plugins.allowedHosts.count': '{n} host(s) permitido(s)', + 'admin.plugins.operatorEgressPill': '+ hosts que você adicionar', + 'admin.plugins.operatorEgressHint': 'Este plugin fala com um serviço que só você pode nomear (um servidor auto-hospedado). Após instalar, adicione os hosts que ele pode alcançar em ⋯ → Hosts permitidos. Nenhum outro.', 'admin.plugins.noErrors': 'Nenhum erro registrado.', 'admin.plugins.uninstalled': 'Plugin desinstalado', 'admin.plugins.uninstallTitle': 'Desinstalar plugin?', @@ -233,33 +243,50 @@ const admin: TranslationStrings = { 'admin.plugins.perm.db:read:users': 'Ler informações básicas de perfil (nome, avatar — nunca credenciais)', 'admin.plugins.perm.db:read:costs': 'Ler despesas (itens de orçamento) às quais o usuário atual tem acesso', 'admin.plugins.perm.db:read:packing': 'Ler listas de bagagem de viagens às quais o usuário atual tem acesso', - 'admin.plugins.perm.db:write:packing': 'Criar, editar e remover itens da lista de bagagem em viagens que o usuário atual pode editar', + 'admin.plugins.perm.db:write:packing': + 'Criar, editar e remover itens da lista de bagagem em viagens que o usuário atual pode editar', 'admin.plugins.perm.weather:read': 'Ler a previsão do tempo em cache do host (por coordenadas)', 'admin.plugins.perm.db:read:categories': 'Ler a lista global de categorias de locais', 'admin.plugins.perm.db:read:tags': 'Ler as etiquetas do usuário atual', 'admin.plugins.perm.db:write:tags': 'Criar, editar e remover as etiquetas do usuário atual', 'admin.plugins.perm.db:read:todos': 'Ler as tarefas de viagens às quais o usuário atual tem acesso', 'admin.plugins.perm.db:write:todos': 'Criar, editar e remover tarefas em viagens que o usuário atual pode editar', - 'admin.plugins.perm.db:write:atlas': 'Marcar países e regiões como visitados e gerenciar a lista de desejos do usuário atual (requer o complemento Atlas)', - 'admin.plugins.perm.db:write:vacay': 'Alternar dias de férias e feriados da empresa no plano ativo do usuário atual (requer o complemento Vacay)', - 'admin.plugins.perm.db:write:journal': 'Criar, editar e remover entradas de diário em diários que o usuário atual pode editar (requer o complemento Journey)', - 'admin.plugins.perm.db:write:collections': 'Criar e editar coleções e salvar locais nelas, com o papel de coleção do usuário atual (requer o complemento Collections)', - 'admin.plugins.perm.db:write:files': 'Anexar arquivos a viagens que o usuário atual pode editar (limite de 10 MB, extensões bloqueadas recusadas) e gerenciar seus vínculos', - 'admin.plugins.perm.db:write:collab': 'Publicar notas, enquetes e mensagens de chat em viagens que o usuário atual pode editar (requer o complemento Collab)', - 'admin.plugins.perm.db:write:members': 'Adicionar usuários a viagens — concede acesso à viagem; protegido pelo direito de gerenciar membros do usuário atual', - 'admin.plugins.perm.notify:send': 'Enviar uma notificação (caixa do sino + e-mail/ntfy) ao usuário atual ou a uma viagem à qual ele pertence — nunca a um destinatário arbitrário', - 'admin.plugins.perm.ai:invoke': 'Usar o provedor de IA configurado pelo administrador (conclusão de texto + extração de documentos) em nome do usuário atual — o complemento nunca guarda uma chave', - 'admin.plugins.perm.oauth:client': 'Conectar-se a um serviço de terceiros em seu nome via OAuth intermediado pelo host (o host guarda os tokens, o plugin nunca os vê)', + 'admin.plugins.perm.db:write:atlas': + 'Marcar países e regiões como visitados e gerenciar a lista de desejos do usuário atual (requer o complemento Atlas)', + 'admin.plugins.perm.db:write:vacay': + 'Alternar dias de férias e feriados da empresa no plano ativo do usuário atual (requer o complemento Vacay)', + 'admin.plugins.perm.db:write:journal': + 'Criar, editar e remover entradas de diário em diários que o usuário atual pode editar (requer o complemento Journey)', + 'admin.plugins.perm.db:write:collections': + 'Criar e editar coleções e salvar locais nelas, com o papel de coleção do usuário atual (requer o complemento Collections)', + 'admin.plugins.perm.db:write:files': + 'Anexar arquivos a viagens que o usuário atual pode editar (limite de 10 MB, extensões bloqueadas recusadas) e gerenciar seus vínculos', + 'admin.plugins.perm.db:write:collab': + 'Publicar notas, enquetes e mensagens de chat em viagens que o usuário atual pode editar (requer o complemento Collab)', + 'admin.plugins.perm.db:write:members': + 'Adicionar usuários a viagens — concede acesso à viagem; protegido pelo direito de gerenciar membros do usuário atual', + 'admin.plugins.perm.notify:send': + 'Enviar uma notificação (caixa do sino + e-mail/ntfy) ao usuário atual ou a uma viagem à qual ele pertence — nunca a um destinatário arbitrário', + 'admin.plugins.perm.ai:invoke': + 'Usar o provedor de IA configurado pelo administrador (conclusão de texto + extração de documentos) em nome do usuário atual — o complemento nunca guarda uma chave', + 'admin.plugins.perm.oauth:client': + 'Conectar-se a um serviço de terceiros em seu nome via OAuth intermediado pelo host (o host guarda os tokens, o plugin nunca os vê)', 'admin.plugins.perm.db:read:files': 'Ler arquivos de viagens às quais o usuário atual tem acesso', - 'admin.plugins.perm.db:write:reservations': 'Criar, editar e remover reservas em viagens que o usuário atual pode editar', - 'admin.plugins.perm.db:write:accommodations': 'Criar, editar e remover acomodações em viagens que o usuário atual pode editar', + 'admin.plugins.perm.db:write:reservations': + 'Criar, editar e remover reservas em viagens que o usuário atual pode editar', + 'admin.plugins.perm.db:write:accommodations': + 'Criar, editar e remover acomodações em viagens que o usuário atual pode editar', 'admin.plugins.perm.db:read:journal': 'Ler os diários de viagem do usuário atual (requer o complemento Journey)', - 'admin.plugins.perm.db:read:atlas': 'Ler os países e regiões visitados pelo usuário atual (requer o complemento Atlas)', + 'admin.plugins.perm.db:read:atlas': + 'Ler os países e regiões visitados pelo usuário atual (requer o complemento Atlas)', 'admin.plugins.perm.db:read:vacay': 'Ler o plano de férias do usuário atual (requer o complemento Vacay)', 'admin.plugins.perm.db:read:daynotes': 'Ler as notas do dia de viagens às quais o usuário atual tem acesso', - 'admin.plugins.perm.db:read:collections': 'Ler as coleções de locais salvos do usuário atual (requer o complemento Collections)', - 'admin.plugins.perm.db:write:daynotes': 'Criar, editar e remover notas do dia em viagens que o usuário atual pode editar', - 'admin.plugins.perm.jobs:run': 'Executar seus jobs em segundo plano de forma agendada (sem contexto de usuário — não pode ler dados do usuário)', + 'admin.plugins.perm.db:read:collections': + 'Ler as coleções de locais salvos do usuário atual (requer o complemento Collections)', + 'admin.plugins.perm.db:write:daynotes': + 'Criar, editar e remover notas do dia em viagens que o usuário atual pode editar', + 'admin.plugins.perm.jobs:run': + 'Executar seus jobs em segundo plano de forma agendada (sem contexto de usuário — não pode ler dados do usuário)', 'admin.plugins.perm.db:write:costs': 'Criar despesas (itens de orçamento) em viagens que o usuário atual pode editar', 'admin.plugins.perm.db:write:places': 'Adicionar, editar e remover locais em viagens que o usuário atual pode editar', 'admin.plugins.perm.db:write:days': 'Adicionar, editar e remover dias em viagens que o usuário atual pode editar', @@ -276,20 +303,27 @@ const admin: TranslationStrings = { 'admin.plugins.perm.hook:place-detail-provider': 'Contribuir com detalhes extras (avaliações, notas, links) para um local', 'admin.plugins.perm.hook:trip-warning-provider': 'Emitir avisos de validação em uma viagem (exibidos no planejador)', - 'admin.plugins.perm.hook:table-contributor': 'Adicionar colunas e ações às visualizações da viagem (reservas, locais, dias)', - 'admin.plugins.perm.hook:map-marker-provider': 'Adicionar marcadores ao mapa da viagem (ex. mostrar reservas ou POIs)', + 'admin.plugins.perm.hook:table-contributor': + 'Adicionar colunas e ações às visualizações da viagem (reservas, locais, dias)', + 'admin.plugins.perm.hook:map-marker-provider': + 'Adicionar marcadores ao mapa da viagem (ex. mostrar reservas ou POIs)', 'admin.plugins.perm.hook:pdf-section-provider': 'Anexar seções de texto à exportação em PDF da viagem', 'admin.plugins.perm.hook:atlas-layer-provider': 'Destacar países no mapa-múndi do Atlas (ex. listas de desejos ou avisos de viagem)', 'admin.plugins.perm.hook:journal-entry-provider': 'Adicionar linhas extras (links, estatísticas) às entradas do diário', - 'admin.plugins.perm.hook:user-data': 'Apagar ou exportar os dados que armazena sobre um usuário (LGPD/GDPR: exclusão de conta e solicitações de dados)', - 'admin.plugins.perm.hook:trip-card-provider': 'Adicionar pequenos selos (status, contagens) aos cartões de viagem do painel', + 'admin.plugins.perm.hook:user-data': + 'Apagar ou exportar os dados que armazena sobre um usuário (LGPD/GDPR: exclusão de conta e solicitações de dados)', + 'admin.plugins.perm.hook:trip-card-provider': + 'Adicionar pequenos selos (status, contagens) aos cartões de viagem do painel', + 'admin.plugins.perm.hook:notification-channel': 'Entregar suas notificações por um canal adicional', 'admin.plugins.perm.events:subscribe': 'Reagir a eventos de atividade do núcleo (apenas nome do evento + viagem, nunca o conteúdo)', 'admin.plugins.perm.http:outbound': 'Fazer requisições de saída para os hosts declarados', - 'admin.plugins.perm.db:read:collab': 'Ler notas, enquetes e mensagens de chat de viagens às quais o usuário atual tem acesso (requer o complemento Collab)', - 'admin.plugins.perm.db:read:files:content': 'Ler o conteúdo (bytes) dos arquivos de viagens às quais o usuário atual tem acesso', + 'admin.plugins.perm.db:read:collab': + 'Ler notas, enquetes e mensagens de chat de viagens às quais o usuário atual tem acesso (requer o complemento Collab)', + 'admin.plugins.perm.db:read:files:content': + 'Ler o conteúdo (bytes) dos arquivos de viagens às quais o usuário atual tem acesso', 'admin.plugins.perm.db:create:trips': 'Criar novas viagens pertencentes ao usuário atual', 'admin.plugins.perm.rates:read': 'Ler as taxas de câmbio em cache do host', 'admin.plugins.updateConsentTitle': 'Esta atualização precisa de novas permissões', @@ -359,6 +393,7 @@ const admin: TranslationStrings = { 'admin.plugins.cap.reservationSlot': 'Detalhe da reserva', 'admin.plugins.cap.replacesTabs': 'Substitui abas do planejador', 'admin.plugins.cap.realtime': 'Atualizações em tempo real', + 'admin.plugins.cap.notificationChannel': 'Canal de notificação', 'admin.plugins.cap.photos': 'Fornece fotos', 'admin.plugins.cap.calendar': 'Fornece eventos de calendário', 'admin.plugins.cap.placeDetails': 'Enriquece locais', diff --git a/shared/src/i18n/br/settings.ts b/shared/src/i18n/br/settings.ts index d2db11cc7..9c39d410f 100644 --- a/shared/src/i18n/br/settings.ts +++ b/shared/src/i18n/br/settings.ts @@ -13,7 +13,8 @@ const settings: TranslationStrings = { 'settings.plugins.oauth.disconnect': 'Desconectar', 'settings.tabs.plugins': 'Plugins', 'settings.plugins.title': 'Configurações de plugins', - 'settings.plugins.subtitle': 'Suas configurações pessoais para os plugins que você usa (chaves de API, preferências).', + 'settings.plugins.subtitle': + 'Suas configurações pessoais para os plugins que você usa (chaves de API, preferências).', 'settings.plugins.empty': 'Nenhum plugin ativo.', 'settings.plugins.saved': 'Configurações salvas', 'settings.tabs.account': 'Conta', @@ -288,6 +289,14 @@ const settings: TranslationStrings = { 'settings.ntfyUrl.testFailed': 'Falha na notificação de teste do Ntfy', 'settings.ntfyUrl.tokenCleared': 'Token de acesso removido', 'settings.notificationPreferences.inapp': 'In-App', + 'settings.notificationPreferences.notConfigured': 'Ainda não configurado — configure nas definições do plugin', + 'settings.plugins.actions': 'Ações', + 'settings.plugins.actions.confirm': 'Executar esta ação?', + 'settings.notificationPreferences.sendTest': 'Enviar teste', + 'settings.notificationPreferences.configure': 'Configurar', + 'settings.notificationPreferences.testSuccess': 'Notificação de teste enviada.', + 'settings.notificationPreferences.testFailed': 'Falha no teste.', + 'settings.notificationPreferences.pluginConfigured': 'Configurado. Gerencie suas credenciais na página de configurações do plugin.', 'settings.notificationPreferences.webhook': 'Webhook', 'settings.notificationPreferences.email': 'Email', 'settings.notificationPreferences.ntfy': 'Ntfy', diff --git a/shared/src/i18n/cs/admin.ts b/shared/src/i18n/cs/admin.ts index 1172ff134..3cb850657 100644 --- a/shared/src/i18n/cs/admin.ts +++ b/shared/src/i18n/cs/admin.ts @@ -167,7 +167,8 @@ const admin: TranslationStrings = { 'admin.plugins.uploaded': 'Plugin “{name}” nahrán — aktivujte jej pro spuštění', 'admin.plugins.sideloaded': 'Načteno ručně', 'admin.plugins.devLinkBadge': 'Dev-Link', - 'admin.plugins.devLinkHint': 'Načteno z místního adresáře sestavení a za běhu znovu načítáno s reálnými daty — jen pro vývoj', + 'admin.plugins.devLinkHint': + 'Načteno z místního adresáře sestavení a za běhu znovu načítáno s reálnými daty — jen pro vývoj', 'admin.plugins.devLinkTitle': 'Připojit místní plugin', 'admin.plugins.devLinkPathPlaceholder': '/absolute/path/to/your/plugin', 'admin.plugins.devLinkButton': 'Připojit', @@ -184,6 +185,15 @@ const admin: TranslationStrings = { 'admin.plugins.deactivated': 'Plugin deaktivován', 'admin.plugins.viewErrors': 'Zobrazit protokol chyb', 'admin.plugins.errorLog': 'Protokol chyb', + 'admin.plugins.allowedHosts': 'Povolení hostitelé', + 'admin.plugins.allowedHosts.hint': 'Tento plugin komunikuje se službou, kterou umíš pojmenovat jen ty (self-hosted server). Přidej hostitele, na které smí — na jiné se nedostane.', + 'admin.plugins.allowedHosts.none': 'Zatím nebyli přidáni žádní hostitelé.', + 'admin.plugins.allowedHosts.unsupported': 'Tento plugin nepoužívá hostitele zadané provozovatelem. Povolení hostitelé jsou pevně dáni v manifestu.', + 'admin.plugins.allowedHosts.restartNote': 'Uložení restartuje plugin, aby načetl nový seznam.', + 'admin.plugins.allowedHosts.add': 'Přidat povoleného hostitele', + 'admin.plugins.allowedHosts.count': '{n} povolených hostitelů', + 'admin.plugins.operatorEgressPill': '+ hostitelé, které přidáš', + 'admin.plugins.operatorEgressHint': 'Tento plugin komunikuje se službou, kterou umíš pojmenovat jen ty (self-hosted server). Po instalaci přidej hostitele v ⋯ → Povolení hostitelé. Na jiné se nedostane.', 'admin.plugins.noErrors': 'Žádné zaznamenané chyby.', 'admin.plugins.uninstalled': 'Plugin odinstalován', 'admin.plugins.uninstallTitle': 'Odinstalovat plugin?', @@ -234,33 +244,50 @@ const admin: TranslationStrings = { 'admin.plugins.perm.db:read:users': 'Číst základní údaje profilu (jméno, avatar — nikdy přihlašovací údaje)', 'admin.plugins.perm.db:read:costs': 'Číst výdaje (položky rozpočtu), ke kterým má aktuální uživatel přístup', 'admin.plugins.perm.db:read:packing': 'Číst seznamy věcí k zabalení u cest, ke kterým má aktuální uživatel přístup', - 'admin.plugins.perm.db:write:packing': 'Vytvářet, upravovat a mazat položky seznamu balení na cestách, které může aktuální uživatel upravovat', + 'admin.plugins.perm.db:write:packing': + 'Vytvářet, upravovat a mazat položky seznamu balení na cestách, které může aktuální uživatel upravovat', 'admin.plugins.perm.weather:read': 'Číst hostitelem uloženou předpověď počasí (podle souřadnic)', 'admin.plugins.perm.db:read:categories': 'Číst globální seznam kategorií míst', 'admin.plugins.perm.db:read:tags': 'Číst štítky aktuálního uživatele', 'admin.plugins.perm.db:write:tags': 'Vytvářet, upravovat a mazat štítky aktuálního uživatele', 'admin.plugins.perm.db:read:todos': 'Číst úkoly cest, ke kterým má aktuální uživatel přístup', - 'admin.plugins.perm.db:write:todos': 'Vytvářet, upravovat a mazat úkoly na cestách, které může aktuální uživatel upravovat', - 'admin.plugins.perm.db:write:atlas': 'Označovat země a regiony jako navštívené a spravovat bucket list aktuálního uživatele (vyžaduje doplněk Atlas)', - 'admin.plugins.perm.db:write:vacay': 'Přepínat dny dovolené a firemní svátky v aktivním plánu aktuálního uživatele (vyžaduje doplněk Vacay)', - 'admin.plugins.perm.db:write:journal': 'Vytvářet, upravovat a mazat záznamy v denících, které může aktuální uživatel upravovat (vyžaduje doplněk Journey)', - 'admin.plugins.perm.db:write:collections': 'Vytvářet a upravovat sbírky a ukládat do nich místa, s rolí aktuálního uživatele ve sbírce (vyžaduje doplněk Collections)', - 'admin.plugins.perm.db:write:files': 'Přikládat soubory k cestám, které může aktuální uživatel upravovat (limit 10 MB, blokované přípony odmítnuty), a spravovat jejich propojení', - 'admin.plugins.perm.db:write:collab': 'Publikovat poznámky, ankety a zprávy chatu na cestách, které může aktuální uživatel upravovat (vyžaduje doplněk Collab)', - 'admin.plugins.perm.db:write:members': 'Přidávat uživatele do cest — uděluje přístup k cestě; chráněno právem správy členů aktuálního uživatele', - 'admin.plugins.perm.notify:send': 'Odeslat oznámení (schránka zvonku + e-mail/ntfy) aktuálnímu uživateli nebo jeho cestě — nikdy libovolnému příjemci', - 'admin.plugins.perm.ai:invoke': 'Použít poskytovatele AI nakonfigurovaného správcem (dokončení textu + extrakce z dokumentů) jménem aktuálního uživatele — doplněk nikdy nedrží klíč', - 'admin.plugins.perm.oauth:client': 'Připojit se k službě třetí strany tvým jménem přes OAuth zprostředkované hostitelem (hostitel drží tokeny, doplněk je nikdy nevidí)', + 'admin.plugins.perm.db:write:todos': + 'Vytvářet, upravovat a mazat úkoly na cestách, které může aktuální uživatel upravovat', + 'admin.plugins.perm.db:write:atlas': + 'Označovat země a regiony jako navštívené a spravovat bucket list aktuálního uživatele (vyžaduje doplněk Atlas)', + 'admin.plugins.perm.db:write:vacay': + 'Přepínat dny dovolené a firemní svátky v aktivním plánu aktuálního uživatele (vyžaduje doplněk Vacay)', + 'admin.plugins.perm.db:write:journal': + 'Vytvářet, upravovat a mazat záznamy v denících, které může aktuální uživatel upravovat (vyžaduje doplněk Journey)', + 'admin.plugins.perm.db:write:collections': + 'Vytvářet a upravovat sbírky a ukládat do nich místa, s rolí aktuálního uživatele ve sbírce (vyžaduje doplněk Collections)', + 'admin.plugins.perm.db:write:files': + 'Přikládat soubory k cestám, které může aktuální uživatel upravovat (limit 10 MB, blokované přípony odmítnuty), a spravovat jejich propojení', + 'admin.plugins.perm.db:write:collab': + 'Publikovat poznámky, ankety a zprávy chatu na cestách, které může aktuální uživatel upravovat (vyžaduje doplněk Collab)', + 'admin.plugins.perm.db:write:members': + 'Přidávat uživatele do cest — uděluje přístup k cestě; chráněno právem správy členů aktuálního uživatele', + 'admin.plugins.perm.notify:send': + 'Odeslat oznámení (schránka zvonku + e-mail/ntfy) aktuálnímu uživateli nebo jeho cestě — nikdy libovolnému příjemci', + 'admin.plugins.perm.ai:invoke': + 'Použít poskytovatele AI nakonfigurovaného správcem (dokončení textu + extrakce z dokumentů) jménem aktuálního uživatele — doplněk nikdy nedrží klíč', + 'admin.plugins.perm.oauth:client': + 'Připojit se k službě třetí strany tvým jménem přes OAuth zprostředkované hostitelem (hostitel drží tokeny, doplněk je nikdy nevidí)', 'admin.plugins.perm.db:read:files': 'Číst soubory cest, ke kterým má aktuální uživatel přístup', - 'admin.plugins.perm.db:write:reservations': 'Vytvářet, upravovat a mazat rezervace na cestách, které může aktuální uživatel upravovat', - 'admin.plugins.perm.db:write:accommodations': 'Vytvářet, upravovat a mazat ubytování na cestách, které může aktuální uživatel upravovat', + 'admin.plugins.perm.db:write:reservations': + 'Vytvářet, upravovat a mazat rezervace na cestách, které může aktuální uživatel upravovat', + 'admin.plugins.perm.db:write:accommodations': + 'Vytvářet, upravovat a mazat ubytování na cestách, které může aktuální uživatel upravovat', 'admin.plugins.perm.db:read:journal': 'Číst cestovní deníky aktuálního uživatele (vyžaduje doplněk Journey)', 'admin.plugins.perm.db:read:atlas': 'Číst navštívené země a regiony aktuálního uživatele (vyžaduje doplněk Atlas)', 'admin.plugins.perm.db:read:vacay': 'Číst plán dovolené aktuálního uživatele (vyžaduje doplněk Vacay)', 'admin.plugins.perm.db:read:daynotes': 'Číst denní poznámky cest, ke kterým má aktuální uživatel přístup', - 'admin.plugins.perm.db:read:collections': 'Číst sbírky uložených míst aktuálního uživatele (vyžaduje doplněk Collections)', - 'admin.plugins.perm.db:write:daynotes': 'Vytvářet, upravovat a mazat denní poznámky na cestách, které může aktuální uživatel upravovat', - 'admin.plugins.perm.jobs:run': 'Spouštět deklarované úlohy na pozadí podle plánu (bez kontextu uživatele — nemůže číst uživatelská data)', + 'admin.plugins.perm.db:read:collections': + 'Číst sbírky uložených míst aktuálního uživatele (vyžaduje doplněk Collections)', + 'admin.plugins.perm.db:write:daynotes': + 'Vytvářet, upravovat a mazat denní poznámky na cestách, které může aktuální uživatel upravovat', + 'admin.plugins.perm.jobs:run': + 'Spouštět deklarované úlohy na pozadí podle plánu (bez kontextu uživatele — nemůže číst uživatelská data)', 'admin.plugins.perm.db:write:costs': 'Vytvářet výdaje (položky rozpočtu) na cestách, které může aktuální uživatel upravovat', 'admin.plugins.perm.db:write:places': @@ -285,12 +312,15 @@ const admin: TranslationStrings = { 'admin.plugins.perm.hook:atlas-layer-provider': 'Zvýrazňovat země na mapě světa v Atlasu (např. seznamy přání nebo cestovní upozornění)', 'admin.plugins.perm.hook:journal-entry-provider': 'Přidávat další řádky (odkazy, statistiky) k záznamům deníku', - 'admin.plugins.perm.hook:user-data': 'Vymazat nebo exportovat data, která o uživateli uchovává (GDPR: smazání účtu a žádosti o data)', + 'admin.plugins.perm.hook:user-data': + 'Vymazat nebo exportovat data, která o uživateli uchovává (GDPR: smazání účtu a žádosti o data)', 'admin.plugins.perm.hook:trip-card-provider': 'Přidávat malé odznaky (stav, počty) na karty cest na nástěnce', + 'admin.plugins.perm.hook:notification-channel': 'Doručovat vaše oznámení dalším kanálem', 'admin.plugins.perm.events:subscribe': 'Reagovat na základní události aktivity (pouze název události a cesta, nikdy obsah)', 'admin.plugins.perm.http:outbound': 'Odesílat odchozí požadavky na deklarované hostitele', - 'admin.plugins.perm.db:read:collab': 'Číst poznámky, ankety a zprávy chatu cest, ke kterým má aktuální uživatel přístup (vyžaduje doplněk Collab)', + 'admin.plugins.perm.db:read:collab': + 'Číst poznámky, ankety a zprávy chatu cest, ke kterým má aktuální uživatel přístup (vyžaduje doplněk Collab)', 'admin.plugins.perm.db:read:files:content': 'Číst obsah (bajty) souborů cest, ke kterým má aktuální uživatel přístup', 'admin.plugins.perm.db:create:trips': 'Vytvářet nové cesty vlastněné aktuálním uživatelem', 'admin.plugins.perm.rates:read': 'Číst hostitelem uložené směnné kurzy měn', @@ -361,6 +391,7 @@ const admin: TranslationStrings = { 'admin.plugins.cap.reservationSlot': 'Detail rezervace', 'admin.plugins.cap.replacesTabs': 'Nahrazuje karty plánovače', 'admin.plugins.cap.realtime': 'Aktualizace v reálném čase', + 'admin.plugins.cap.notificationChannel': 'Kanál oznámení', 'admin.plugins.cap.photos': 'Poskytuje fotky', 'admin.plugins.cap.calendar': 'Poskytuje události kalendáře', 'admin.plugins.cap.placeDetails': 'Obohacuje místa', diff --git a/shared/src/i18n/cs/settings.ts b/shared/src/i18n/cs/settings.ts index b6df985b1..623f704c3 100644 --- a/shared/src/i18n/cs/settings.ts +++ b/shared/src/i18n/cs/settings.ts @@ -284,6 +284,14 @@ const settings: TranslationStrings = { 'settings.ntfyUrl.testFailed': 'Testovací notifikace Ntfy selhala', '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.plugins.actions': 'Akce', + 'settings.plugins.actions.confirm': 'Spustit tuto akci?', + 'settings.notificationPreferences.sendTest': 'Odeslat test', + 'settings.notificationPreferences.configure': 'Nastavit', + 'settings.notificationPreferences.testSuccess': 'Testovací oznámení odesláno.', + 'settings.notificationPreferences.testFailed': 'Test selhal.', + 'settings.notificationPreferences.pluginConfigured': 'Nastaveno. Přihlašovací údaje spravuj v nastavení pluginu.', 'settings.notificationPreferences.webhook': 'Webhook', 'settings.notificationPreferences.email': 'Email', 'settings.notificationPreferences.ntfy': 'Ntfy', diff --git a/shared/src/i18n/de/admin.ts b/shared/src/i18n/de/admin.ts index 54c9c414b..b28d0a495 100644 --- a/shared/src/i18n/de/admin.ts +++ b/shared/src/i18n/de/admin.ts @@ -169,7 +169,8 @@ const admin: TranslationStrings = { 'admin.plugins.uploaded': 'Plugin „{name}“ hochgeladen — zum Ausführen aktivieren', 'admin.plugins.sideloaded': 'Manuell geladen', 'admin.plugins.devLinkBadge': 'Dev-Link', - 'admin.plugins.devLinkHint': 'Aus einem lokalen Build-Verzeichnis geladen und gegen echte Daten neu geladen — nur für Entwicklung', + 'admin.plugins.devLinkHint': + 'Aus einem lokalen Build-Verzeichnis geladen und gegen echte Daten neu geladen — nur für Entwicklung', 'admin.plugins.devLinkTitle': 'Lokales Plugin verknüpfen', 'admin.plugins.devLinkPathPlaceholder': '/absolute/path/to/your/plugin', 'admin.plugins.devLinkButton': 'Verknüpfen', @@ -186,6 +187,15 @@ const admin: TranslationStrings = { 'admin.plugins.deactivated': 'Plugin deaktiviert', 'admin.plugins.viewErrors': 'Fehlerprotokoll ansehen', 'admin.plugins.errorLog': 'Fehlerprotokoll', + 'admin.plugins.allowedHosts': 'Erlaubte Hosts', + 'admin.plugins.allowedHosts.hint': 'Dieses Plugin spricht mit einem Dienst, den nur du benennen kannst (ein selbst gehosteter Server). Füge die Hosts hinzu, die es erreichen darf — andere erreicht es nicht.', + 'admin.plugins.allowedHosts.none': 'Noch keine Hosts hinzugefügt.', + 'admin.plugins.allowedHosts.unsupported': 'Dieses Plugin nutzt keine vom Betreiber angegebenen Hosts. Seine erlaubten Hosts stehen fest im Manifest.', + 'admin.plugins.allowedHosts.restartNote': 'Beim Speichern wird das Plugin neu gestartet, damit es die neue Liste übernimmt.', + 'admin.plugins.allowedHosts.add': 'Erlaubten Host hinzufügen', + 'admin.plugins.allowedHosts.count': '{n} erlaubte Hosts', + 'admin.plugins.operatorEgressPill': '+ von dir ergänzte Hosts', + 'admin.plugins.operatorEgressHint': 'Dieses Plugin spricht mit einem Dienst, den nur du benennen kannst (ein selbst gehosteter Server). Füge nach der Installation unter ⋯ → Erlaubte Hosts die erreichbaren Hosts hinzu. Andere erreicht es nicht.', 'admin.plugins.noErrors': 'Keine Fehler protokolliert.', 'admin.plugins.uninstalled': 'Plugin deinstalliert', 'admin.plugins.uninstallTitle': 'Plugin deinstallieren?', @@ -236,29 +246,43 @@ const admin: TranslationStrings = { 'admin.plugins.perm.db:read:users': 'Grundlegende Profilinfos lesen (Name, Avatar — nie Zugangsdaten)', 'admin.plugins.perm.db:read:costs': 'Ausgaben (Budgetposten) lesen, auf die der handelnde Nutzer Zugriff hat', 'admin.plugins.perm.db:read:packing': 'Packlisten von Reisen lesen, auf die der handelnde Nutzer Zugriff hat', - 'admin.plugins.perm.db:write:packing': 'Packlisten-Einträge auf Reisen anlegen, bearbeiten und löschen, die der handelnde Nutzer bearbeiten kann', + 'admin.plugins.perm.db:write:packing': + 'Packlisten-Einträge auf Reisen anlegen, bearbeiten und löschen, die der handelnde Nutzer bearbeiten kann', 'admin.plugins.perm.weather:read': 'Die zwischengespeicherte Wettervorhersage des Hosts lesen (nach Koordinaten)', 'admin.plugins.perm.db:read:categories': 'Die globale Orts-Kategorienliste lesen', 'admin.plugins.perm.db:read:tags': 'Die eigenen Tags des handelnden Nutzers lesen', 'admin.plugins.perm.db:write:tags': 'Die eigenen Tags des handelnden Nutzers anlegen, bearbeiten und löschen', 'admin.plugins.perm.db:read:todos': 'To-dos von Reisen lesen, auf die der handelnde Nutzer Zugriff hat', - 'admin.plugins.perm.db:write:todos': 'To-dos auf Reisen anlegen, bearbeiten und löschen, die der handelnde Nutzer bearbeiten kann', - 'admin.plugins.perm.db:write:atlas': 'Länder und Regionen als besucht markieren und die Bucket-List des handelnden Nutzers verwalten (benötigt das Atlas-Addon)', - 'admin.plugins.perm.db:write:vacay': 'Urlaubstage und Betriebsferien im aktiven Plan des handelnden Nutzers umschalten (benötigt das Vacay-Addon)', - 'admin.plugins.perm.db:write:journal': 'Tagebucheinträge in Reisetagebüchern anlegen, bearbeiten und löschen, die der handelnde Nutzer bearbeiten kann (benötigt das Journey-Addon)', - 'admin.plugins.perm.db:write:collections': 'Sammlungen anlegen und bearbeiten und Orte darin speichern, mit der Sammlungsrolle des handelnden Nutzers (benötigt das Collections-Addon)', - 'admin.plugins.perm.db:write:files': 'Dateien an Reisen anhängen, die der handelnde Nutzer bearbeiten kann (10-MB-Limit, gesperrte Endungen abgelehnt), und deren Verknüpfungen verwalten', - 'admin.plugins.perm.db:write:collab': 'Notizen, Umfragen und Chat-Nachrichten auf Reisen erstellen, die der handelnde Nutzer bearbeiten kann (benötigt das Collab-Addon)', - 'admin.plugins.perm.db:write:members': 'Nutzer zu Reisen hinzufügen — gewährt Reise-Zugriff; über das Mitglieder-verwalten-Recht des handelnden Nutzers abgesichert', - 'admin.plugins.perm.notify:send': 'Eine Benachrichtigung (Glocken-Postfach + E-Mail/ntfy) an den handelnden Nutzer oder eine seiner Reisen senden — nie an beliebige Empfänger', - 'admin.plugins.perm.ai:invoke': 'Den vom Admin konfigurierten KI-Anbieter (Textvervollständigung + Dokumentenextraktion) im Namen des handelnden Nutzers nutzen — das Plugin hält nie einen Schlüssel', - 'admin.plugins.perm.oauth:client': 'Über host-vermitteltes OAuth in deinem Namen mit einem Drittanbieter verbinden (der Host hält die Tokens, das Plugin sieht sie nie)', + 'admin.plugins.perm.db:write:todos': + 'To-dos auf Reisen anlegen, bearbeiten und löschen, die der handelnde Nutzer bearbeiten kann', + 'admin.plugins.perm.db:write:atlas': + 'Länder und Regionen als besucht markieren und die Bucket-List des handelnden Nutzers verwalten (benötigt das Atlas-Addon)', + 'admin.plugins.perm.db:write:vacay': + 'Urlaubstage und Betriebsferien im aktiven Plan des handelnden Nutzers umschalten (benötigt das Vacay-Addon)', + 'admin.plugins.perm.db:write:journal': + 'Tagebucheinträge in Reisetagebüchern anlegen, bearbeiten und löschen, die der handelnde Nutzer bearbeiten kann (benötigt das Journey-Addon)', + 'admin.plugins.perm.db:write:collections': + 'Sammlungen anlegen und bearbeiten und Orte darin speichern, mit der Sammlungsrolle des handelnden Nutzers (benötigt das Collections-Addon)', + 'admin.plugins.perm.db:write:files': + 'Dateien an Reisen anhängen, die der handelnde Nutzer bearbeiten kann (10-MB-Limit, gesperrte Endungen abgelehnt), und deren Verknüpfungen verwalten', + 'admin.plugins.perm.db:write:collab': + 'Notizen, Umfragen und Chat-Nachrichten auf Reisen erstellen, die der handelnde Nutzer bearbeiten kann (benötigt das Collab-Addon)', + 'admin.plugins.perm.db:write:members': + 'Nutzer zu Reisen hinzufügen — gewährt Reise-Zugriff; über das Mitglieder-verwalten-Recht des handelnden Nutzers abgesichert', + 'admin.plugins.perm.notify:send': + 'Eine Benachrichtigung (Glocken-Postfach + E-Mail/ntfy) an den handelnden Nutzer oder eine seiner Reisen senden — nie an beliebige Empfänger', + 'admin.plugins.perm.ai:invoke': + 'Den vom Admin konfigurierten KI-Anbieter (Textvervollständigung + Dokumentenextraktion) im Namen des handelnden Nutzers nutzen — das Plugin hält nie einen Schlüssel', + 'admin.plugins.perm.oauth:client': + 'Über host-vermitteltes OAuth in deinem Namen mit einem Drittanbieter verbinden (der Host hält die Tokens, das Plugin sieht sie nie)', 'admin.plugins.perm.db:read:files': 'Dateien von Reisen lesen, auf die der handelnde Nutzer Zugriff hat', 'admin.plugins.perm.db:read:journal': 'Reisetagebücher des handelnden Nutzers lesen (benötigt das Journey-Addon)', - 'admin.plugins.perm.db:read:atlas': 'Besuchte Länder und Regionen des handelnden Nutzers lesen (benötigt das Atlas-Addon)', + 'admin.plugins.perm.db:read:atlas': + 'Besuchte Länder und Regionen des handelnden Nutzers lesen (benötigt das Atlas-Addon)', 'admin.plugins.perm.db:read:vacay': 'Urlaubsplanung des handelnden Nutzers lesen (benötigt das Vacay-Addon)', 'admin.plugins.perm.db:read:daynotes': 'Tagesnotizen von Reisen lesen, auf die der handelnde Nutzer Zugriff hat', - 'admin.plugins.perm.db:read:collections': 'Sammlungen gespeicherter Orte des handelnden Nutzers lesen (benötigt das Collections-Addon)', + 'admin.plugins.perm.db:read:collections': + 'Sammlungen gespeicherter Orte des handelnden Nutzers lesen (benötigt das Collections-Addon)', 'admin.plugins.perm.db:write:costs': 'Ausgaben (Budgetposten) auf Reisen erstellen, die der handelnde Nutzer bearbeiten kann', 'admin.plugins.perm.db:write:places': @@ -271,7 +295,8 @@ const admin: TranslationStrings = { 'Reisedetails (Titel, Daten, Währung …) auf Reisen bearbeiten, die der handelnde Nutzer bearbeiten kann', 'admin.plugins.perm.db:write:reservations': 'Buchungen (Reservierungen) auf Reisen anlegen, bearbeiten und löschen, die der handelnde Nutzer bearbeiten kann', - 'admin.plugins.perm.db:write:accommodations': 'Unterkünfte (Übernachtungsblöcke) auf Reisen anlegen, bearbeiten und löschen, die der handelnde Nutzer bearbeiten darf', + 'admin.plugins.perm.db:write:accommodations': + 'Unterkünfte (Übernachtungsblöcke) auf Reisen anlegen, bearbeiten und löschen, die der handelnde Nutzer bearbeiten darf', 'admin.plugins.perm.db:write:daynotes': 'Tagesnotizen auf Reisen anlegen, bearbeiten und löschen, die der handelnde Nutzer bearbeiten kann', 'admin.plugins.perm.db:meta': @@ -284,20 +309,28 @@ const admin: TranslationStrings = { 'Zusatzdetails (Bewertungen, Ratings, Links) zu einem Ort beisteuern', 'admin.plugins.perm.hook:trip-warning-provider': 'Validierungswarnungen zu einer Reise ausgeben (im Planer angezeigt)', - 'admin.plugins.perm.hook:table-contributor': 'Spalten und Aktionen zu Reise-Ansichten hinzufügen (Reservierungen, Orte, Tage)', - 'admin.plugins.perm.hook:map-marker-provider': 'Marker zur Reisekarte hinzufügen (z. B. Buchungen oder POIs anzeigen)', + 'admin.plugins.perm.hook:table-contributor': + 'Spalten und Aktionen zu Reise-Ansichten hinzufügen (Reservierungen, Orte, Tage)', + 'admin.plugins.perm.hook:map-marker-provider': + 'Marker zur Reisekarte hinzufügen (z. B. Buchungen oder POIs anzeigen)', 'admin.plugins.perm.hook:pdf-section-provider': 'Textabschnitte an den PDF-Export einer Reise anhängen', 'admin.plugins.perm.hook:atlas-layer-provider': 'Länder auf der Atlas-Weltkarte hervorheben (z. B. Wunschlisten oder Reisehinweise)', 'admin.plugins.perm.hook:journal-entry-provider': 'Zusatzzeilen (Links, Statistiken) zu Journal-Einträgen beisteuern', - 'admin.plugins.perm.hook:user-data': 'Von ihm gespeicherte Nutzerdaten löschen oder exportieren (DSGVO: Kontolöschung & Auskunft)', - 'admin.plugins.perm.hook:trip-card-provider': 'Kleine Badges (Status, Zähler) zu deinen Dashboard-Trip-Karten hinzufügen', + 'admin.plugins.perm.hook:user-data': + 'Von ihm gespeicherte Nutzerdaten löschen oder exportieren (DSGVO: Kontolöschung & Auskunft)', + 'admin.plugins.perm.hook:trip-card-provider': + 'Kleine Badges (Status, Zähler) zu deinen Dashboard-Trip-Karten hinzufügen', + 'admin.plugins.perm.hook:notification-channel': 'Deine Benachrichtigungen über einen zusätzlichen Kanal zustellen', 'admin.plugins.perm.events:subscribe': 'Auf Kern-Aktivitäts-Events reagieren (nur Event-Name + Reise, nie der Inhalt)', - 'admin.plugins.perm.jobs:run': 'Deklarierte Hintergrund-Jobs zeitgesteuert ausführen (kein Nutzerkontext — kann keine Nutzerdaten lesen)', + 'admin.plugins.perm.jobs:run': + 'Deklarierte Hintergrund-Jobs zeitgesteuert ausführen (kein Nutzerkontext — kann keine Nutzerdaten lesen)', 'admin.plugins.perm.http:outbound': 'Ausgehende Anfragen an deklarierte Hosts stellen', - 'admin.plugins.perm.db:read:collab': 'Notizen, Umfragen und Chat-Nachrichten von Reisen lesen, auf die der handelnde Nutzer Zugriff hat (benötigt das Collab-Addon)', - 'admin.plugins.perm.db:read:files:content': 'Dateiinhalte (Bytes) von Reisen lesen, auf die der handelnde Nutzer Zugriff hat', + 'admin.plugins.perm.db:read:collab': + 'Notizen, Umfragen und Chat-Nachrichten von Reisen lesen, auf die der handelnde Nutzer Zugriff hat (benötigt das Collab-Addon)', + 'admin.plugins.perm.db:read:files:content': + 'Dateiinhalte (Bytes) von Reisen lesen, auf die der handelnde Nutzer Zugriff hat', 'admin.plugins.perm.db:create:trips': 'Neue Reisen im Besitz des handelnden Nutzers anlegen', 'admin.plugins.perm.rates:read': 'Die zwischengespeicherten Wechselkurse des Hosts lesen', 'admin.plugins.updateConsentTitle': 'Dieses Update braucht neue Berechtigungen', @@ -367,6 +400,7 @@ const admin: TranslationStrings = { 'admin.plugins.cap.reservationSlot': 'Reservierungsdetails', 'admin.plugins.cap.replacesTabs': 'Ersetzt Planer-Tabs', 'admin.plugins.cap.realtime': 'Echtzeit-Updates', + 'admin.plugins.cap.notificationChannel': 'Benachrichtigungskanal', 'admin.plugins.cap.photos': 'Liefert Fotos', 'admin.plugins.cap.calendar': 'Liefert Kalender-Events', 'admin.plugins.cap.placeDetails': 'Reichert Orte an', diff --git a/shared/src/i18n/de/settings.ts b/shared/src/i18n/de/settings.ts index cf31f13f4..7e57737c5 100644 --- a/shared/src/i18n/de/settings.ts +++ b/shared/src/i18n/de/settings.ts @@ -13,7 +13,8 @@ const settings: TranslationStrings = { 'settings.plugins.oauth.disconnect': 'Trennen', 'settings.tabs.plugins': 'Plugins', 'settings.plugins.title': 'Plugin-Einstellungen', - 'settings.plugins.subtitle': 'Deine persönlichen Einstellungen für die von dir genutzten Plugins (API-Schlüssel, Präferenzen).', + 'settings.plugins.subtitle': + 'Deine persönlichen Einstellungen für die von dir genutzten Plugins (API-Schlüssel, Präferenzen).', 'settings.plugins.empty': 'Es sind keine Plugins aktiv.', 'settings.plugins.saved': 'Einstellungen gespeichert', 'settings.tabs.account': 'Konto', @@ -289,6 +290,15 @@ const settings: TranslationStrings = { 'settings.ntfyUrl.testFailed': 'Test-Ntfy-Benachrichtigung fehlgeschlagen', 'settings.ntfyUrl.tokenCleared': 'Zugriffstoken gelöscht', 'settings.notificationPreferences.inapp': 'In-App', + 'settings.notificationPreferences.notConfigured': + 'Noch nicht eingerichtet — in den Einstellungen des Plugins konfigurieren', + 'settings.plugins.actions': 'Aktionen', + 'settings.plugins.actions.confirm': 'Diese Aktion ausführen?', + 'settings.notificationPreferences.sendTest': 'Test senden', + 'settings.notificationPreferences.configure': 'Einrichten', + 'settings.notificationPreferences.testSuccess': 'Testbenachrichtigung gesendet.', + 'settings.notificationPreferences.testFailed': 'Test fehlgeschlagen.', + 'settings.notificationPreferences.pluginConfigured': 'Eingerichtet. Zugangsdaten auf der Einstellungsseite des Plugins verwalten.', 'settings.notificationPreferences.webhook': 'Webhook', 'settings.notificationPreferences.email': 'Email', 'settings.notificationPreferences.ntfy': 'Ntfy', diff --git a/shared/src/i18n/en/admin.ts b/shared/src/i18n/en/admin.ts index fe37499b4..e2b12b8f3 100644 --- a/shared/src/i18n/en/admin.ts +++ b/shared/src/i18n/en/admin.ts @@ -238,6 +238,15 @@ const admin: TranslationStrings = { 'admin.plugins.deactivated': 'Plugin deactivated', 'admin.plugins.viewErrors': 'View error log', 'admin.plugins.errorLog': 'Error log', + 'admin.plugins.allowedHosts': 'Allowed hosts', + 'admin.plugins.allowedHosts.hint': 'This plugin talks to a service only you can name (a self-hosted server). Add the hosts it may reach — it can reach no others.', + 'admin.plugins.allowedHosts.none': 'No hosts added yet.', + 'admin.plugins.allowedHosts.unsupported': 'This plugin does not use operator-supplied hosts. Its allowed hosts are fixed in its manifest.', + 'admin.plugins.allowedHosts.restartNote': 'Saving restarts the plugin so it picks up the new list.', + 'admin.plugins.allowedHosts.add': 'Add allowed host', + 'admin.plugins.allowedHosts.count': '{n} allowed host(s)', + 'admin.plugins.operatorEgressPill': '+ hosts you add', + 'admin.plugins.operatorEgressHint': 'This plugin talks to a service only you can name (a self-hosted server). After installing, add the hosts it may reach under ⋯ → Allowed hosts. It can reach no others.', 'admin.plugins.noErrors': 'No errors logged.', 'admin.plugins.uninstalled': 'Plugin uninstalled', 'admin.plugins.uninstallTitle': 'Uninstall plugin?', @@ -295,29 +304,43 @@ const admin: TranslationStrings = { 'admin.plugins.perm.db:write:tags': "Create, edit and delete the acting user's own tags", 'admin.plugins.perm.db:read:todos': 'Read the to-dos of trips the acting user can access', 'admin.plugins.perm.db:write:todos': 'Create, edit and delete to-dos on trips the acting user can edit', - 'admin.plugins.perm.db:write:atlas': 'Mark countries and regions visited and manage the bucket list of the acting user (needs the Atlas addon)', - 'admin.plugins.perm.db:write:vacay': "Toggle vacation days and company holidays on the acting user's active plan (needs the Vacay addon)", - 'admin.plugins.perm.db:write:journal': 'Create, edit and delete journal entries on journeys the acting user can edit (needs the Journey addon)', - 'admin.plugins.perm.db:write:collections': "Create and edit collections and save places to them, with the acting user's collection role (needs the Collections addon)", - 'admin.plugins.perm.db:write:files': 'Attach files to trips the acting user can edit (10MB cap, blocked extensions refused) and manage their links', - 'admin.plugins.perm.db:write:collab': 'Post notes, polls and chat messages on trips the acting user can edit (needs the Collab addon)', - 'admin.plugins.perm.db:write:members': "Add users to trips — grants trip access; gated by the acting user's member-manage right", - 'admin.plugins.perm.notify:send': 'Send a notification (bell inbox + email/ntfy) to the acting user or a trip they belong to — never an arbitrary recipient', - 'admin.plugins.perm.ai:invoke': "Run the admin-configured AI provider (text completion + document extraction) on the acting user's behalf — the plugin never holds a key", - 'admin.plugins.perm.oauth:client': 'Connect to a third-party service on your behalf via host-brokered OAuth (the host holds the tokens, the plugin never sees them)', + 'admin.plugins.perm.db:write:atlas': + 'Mark countries and regions visited and manage the bucket list of the acting user (needs the Atlas addon)', + 'admin.plugins.perm.db:write:vacay': + "Toggle vacation days and company holidays on the acting user's active plan (needs the Vacay addon)", + 'admin.plugins.perm.db:write:journal': + 'Create, edit and delete journal entries on journeys the acting user can edit (needs the Journey addon)', + 'admin.plugins.perm.db:write:collections': + "Create and edit collections and save places to them, with the acting user's collection role (needs the Collections addon)", + 'admin.plugins.perm.db:write:files': + 'Attach files to trips the acting user can edit (10MB cap, blocked extensions refused) and manage their links', + 'admin.plugins.perm.db:write:collab': + 'Post notes, polls and chat messages on trips the acting user can edit (needs the Collab addon)', + 'admin.plugins.perm.db:write:members': + "Add users to trips — grants trip access; gated by the acting user's member-manage right", + 'admin.plugins.perm.notify:send': + 'Send a notification (bell inbox + email/ntfy) to the acting user or a trip they belong to — never an arbitrary recipient', + 'admin.plugins.perm.ai:invoke': + "Run the admin-configured AI provider (text completion + document extraction) on the acting user's behalf — the plugin never holds a key", + 'admin.plugins.perm.oauth:client': + 'Connect to a third-party service on your behalf via host-brokered OAuth (the host holds the tokens, the plugin never sees them)', 'admin.plugins.perm.db:read:files': 'Read files of trips the acting user can access', 'admin.plugins.perm.db:read:journal': 'Read the travel journals of the acting user (needs the Journey addon)', - 'admin.plugins.perm.db:read:atlas': 'Read the visited countries and regions of the acting user (needs the Atlas addon)', + 'admin.plugins.perm.db:read:atlas': + 'Read the visited countries and regions of the acting user (needs the Atlas addon)', 'admin.plugins.perm.db:read:vacay': 'Read the vacation plan of the acting user (needs the Vacay addon)', 'admin.plugins.perm.db:read:daynotes': 'Read the day notes of trips the acting user can access', - 'admin.plugins.perm.db:read:collections': 'Read the saved-place collections of the acting user (needs the Collections addon)', + 'admin.plugins.perm.db:read:collections': + 'Read the saved-place collections of the acting user (needs the Collections addon)', 'admin.plugins.perm.db:write:costs': 'Create costs (budget items) on trips the acting user can edit', 'admin.plugins.perm.db:write:places': 'Add, edit and remove places on trips the acting user can edit', 'admin.plugins.perm.db:write:days': 'Add, edit and remove days on trips the acting user can edit', 'admin.plugins.perm.db:write:itinerary': 'Assign and remove places on days of trips the acting user can edit', 'admin.plugins.perm.db:write:trips': 'Edit trip details (title, dates, currency…) on trips the acting user can edit', - 'admin.plugins.perm.db:write:reservations': 'Create, edit and delete bookings (reservations) on trips the acting user can edit', - 'admin.plugins.perm.db:write:accommodations': 'Create, edit and delete accommodations (lodging blocks) on trips the acting user can edit', + 'admin.plugins.perm.db:write:reservations': + 'Create, edit and delete bookings (reservations) on trips the acting user can edit', + 'admin.plugins.perm.db:write:accommodations': + 'Create, edit and delete accommodations (lodging blocks) on trips the acting user can edit', 'admin.plugins.perm.db:write:daynotes': 'Create, edit and delete day notes on trips the acting user can edit', 'admin.plugins.perm.db:meta': 'Attach its own private data to trips, places and days the acting user can access', 'admin.plugins.perm.ws:broadcast:trip': 'Send real-time updates to trip members', @@ -333,11 +356,15 @@ const admin: TranslationStrings = { 'Highlight countries on the Atlas world map (e.g. wishlists or travel advisories)', 'admin.plugins.perm.hook:journal-entry-provider': 'Contribute extra rows (links, stats) to journal entries', 'admin.plugins.perm.hook:trip-card-provider': 'Add small badges (status, counts) to your dashboard trip cards', - 'admin.plugins.perm.hook:user-data': 'Erase or export the data it stores about a user (GDPR account deletion & data requests)', + 'admin.plugins.perm.hook:notification-channel': 'Deliver your notifications over an additional channel', + 'admin.plugins.perm.hook:user-data': + 'Erase or export the data it stores about a user (GDPR account deletion & data requests)', 'admin.plugins.perm.events:subscribe': 'React to core activity events (event name + trip only, never the content)', - 'admin.plugins.perm.jobs:run': 'Run its declared background jobs on a schedule (no user context — cannot read user data)', + 'admin.plugins.perm.jobs:run': + 'Run its declared background jobs on a schedule (no user context — cannot read user data)', 'admin.plugins.perm.http:outbound': 'Make outbound requests to its declared hosts', - 'admin.plugins.perm.db:read:collab': 'Read notes, polls and chat messages of trips the acting user can access (needs the Collab addon)', + 'admin.plugins.perm.db:read:collab': + 'Read notes, polls and chat messages of trips the acting user can access (needs the Collab addon)', 'admin.plugins.perm.db:read:files:content': 'Read the byte content of files on trips the acting user can access', 'admin.plugins.perm.db:create:trips': 'Create new trips owned by the acting user', 'admin.plugins.perm.rates:read': "Read the host's cached currency exchange rates", @@ -408,6 +435,7 @@ const admin: TranslationStrings = { 'admin.plugins.cap.reservationSlot': 'Reservation detail', 'admin.plugins.cap.replacesTabs': 'Replaces planner tabs', 'admin.plugins.cap.realtime': 'Real-time updates', + 'admin.plugins.cap.notificationChannel': 'Notification channel', 'admin.plugins.cap.photos': 'Provides photos', 'admin.plugins.cap.calendar': 'Provides calendar events', 'admin.plugins.cap.placeDetails': 'Enriches places', diff --git a/shared/src/i18n/en/settings.ts b/shared/src/i18n/en/settings.ts index 5638c1e20..d551b4731 100644 --- a/shared/src/i18n/en/settings.ts +++ b/shared/src/i18n/en/settings.ts @@ -101,6 +101,14 @@ const settings: TranslationStrings = { 'settings.notificationPreferences.email': 'Email', 'settings.notificationPreferences.webhook': 'Webhook', 'settings.notificationPreferences.inapp': 'In-App', + 'settings.notificationPreferences.notConfigured': 'Not configured yet — set it up in the plugin’s settings', + 'settings.plugins.actions': 'Actions', + 'settings.plugins.actions.confirm': 'Run this action?', + 'settings.notificationPreferences.sendTest': 'Send test', + 'settings.notificationPreferences.configure': 'Configure', + 'settings.notificationPreferences.testSuccess': 'Test notification sent.', + 'settings.notificationPreferences.testFailed': 'Test failed.', + 'settings.notificationPreferences.pluginConfigured': 'Configured. Manage credentials on the plugin’s settings page.', 'settings.notificationPreferences.ntfy': 'Ntfy', 'settings.notificationPreferences.noChannels': 'No notification channels are configured. Ask an admin to set up email or webhook notifications.', diff --git a/shared/src/i18n/es/admin.ts b/shared/src/i18n/es/admin.ts index 3dd1d082b..b83b69c55 100644 --- a/shared/src/i18n/es/admin.ts +++ b/shared/src/i18n/es/admin.ts @@ -168,7 +168,8 @@ const admin: TranslationStrings = { 'admin.plugins.uploaded': 'Plugin “{name}” subido — actívalo para ejecutarlo', 'admin.plugins.sideloaded': 'Cargado manualmente', 'admin.plugins.devLinkBadge': 'Dev-Link', - 'admin.plugins.devLinkHint': 'Cargado desde un directorio de compilación local y recargado en caliente con datos reales: solo para desarrollo', + 'admin.plugins.devLinkHint': + 'Cargado desde un directorio de compilación local y recargado en caliente con datos reales: solo para desarrollo', 'admin.plugins.devLinkTitle': 'Vincular un plugin local', 'admin.plugins.devLinkPathPlaceholder': '/absolute/path/to/your/plugin', 'admin.plugins.devLinkButton': 'Vincular', @@ -185,6 +186,15 @@ const admin: TranslationStrings = { 'admin.plugins.deactivated': 'Plugin desactivado', 'admin.plugins.viewErrors': 'Ver registro de errores', 'admin.plugins.errorLog': 'Registro de errores', + 'admin.plugins.allowedHosts': 'Hosts permitidos', + 'admin.plugins.allowedHosts.hint': 'Este plugin se comunica con un servicio que solo tú puedes nombrar (un servidor autoalojado). Añade los hosts a los que puede acceder — no accederá a ningún otro.', + 'admin.plugins.allowedHosts.none': 'Aún no se han añadido hosts.', + 'admin.plugins.allowedHosts.unsupported': 'Este plugin no usa hosts proporcionados por el operador. Sus hosts permitidos están fijados en su manifiesto.', + 'admin.plugins.allowedHosts.restartNote': 'Al guardar se reinicia el plugin para que tome la nueva lista.', + 'admin.plugins.allowedHosts.add': 'Añadir host permitido', + 'admin.plugins.allowedHosts.count': '{n} host(s) permitido(s)', + 'admin.plugins.operatorEgressPill': '+ hosts que añadas', + 'admin.plugins.operatorEgressHint': 'Este plugin se comunica con un servicio que solo tú puedes nombrar (un servidor autoalojado). Tras instalarlo, añade los hosts a los que puede acceder en ⋯ → Hosts permitidos. No accederá a ningún otro.', 'admin.plugins.noErrors': 'No hay errores registrados.', 'admin.plugins.uninstalled': 'Plugin desinstalado', 'admin.plugins.uninstallTitle': '¿Desinstalar el plugin?', @@ -237,33 +247,53 @@ const admin: TranslationStrings = { 'Leer los gastos (partidas de presupuesto) a los que el usuario activo tiene acceso', 'admin.plugins.perm.db:read:packing': 'Leer las listas de equipaje de los viajes a los que el usuario activo tiene acceso', - 'admin.plugins.perm.db:write:packing': 'Crear, editar y eliminar artículos de la lista de equipaje en los viajes que el usuario activo puede editar', + 'admin.plugins.perm.db:write:packing': + 'Crear, editar y eliminar artículos de la lista de equipaje en los viajes que el usuario activo puede editar', 'admin.plugins.perm.weather:read': 'Leer la previsión meteorológica en caché del host (por coordenadas)', 'admin.plugins.perm.db:read:categories': 'Leer la lista global de categorías de lugares', 'admin.plugins.perm.db:read:tags': 'Leer las etiquetas del usuario activo', 'admin.plugins.perm.db:write:tags': 'Crear, editar y eliminar las etiquetas del usuario activo', 'admin.plugins.perm.db:read:todos': 'Leer las tareas de los viajes a los que el usuario activo tiene acceso', - 'admin.plugins.perm.db:write:todos': 'Crear, editar y eliminar tareas en los viajes que el usuario activo puede editar', - 'admin.plugins.perm.db:write:atlas': 'Marcar países y regiones como visitados y gestionar la lista de deseos del usuario activo (requiere el complemento Atlas)', - 'admin.plugins.perm.db:write:vacay': 'Alternar días de vacaciones y festivos de empresa en el plan activo del usuario activo (requiere el complemento Vacay)', - 'admin.plugins.perm.db:write:journal': 'Crear, editar y eliminar entradas de diario en los diarios que el usuario activo puede editar (requiere el complemento Journey)', - 'admin.plugins.perm.db:write:collections': 'Crear y editar colecciones y guardar lugares en ellas, con el rol de colección del usuario activo (requiere el complemento Collections)', - 'admin.plugins.perm.db:write:files': 'Adjuntar archivos a los viajes que el usuario activo puede editar (límite de 10 MB, extensiones bloqueadas rechazadas) y gestionar sus enlaces', - 'admin.plugins.perm.db:write:collab': 'Publicar notas, encuestas y mensajes de chat en los viajes que el usuario activo puede editar (requiere el complemento Collab)', - 'admin.plugins.perm.db:write:members': 'Añadir usuarios a los viajes — concede acceso al viaje; protegido por el permiso de gestión de miembros del usuario activo', - 'admin.plugins.perm.notify:send': 'Enviar una notificación (bandeja de campana + correo/ntfy) al usuario activo o a un viaje al que pertenece — nunca a un destinatario arbitrario', - 'admin.plugins.perm.ai:invoke': 'Usar el proveedor de IA configurado por el administrador (completado de texto + extracción de documentos) en nombre del usuario activo — el complemento nunca guarda una clave', - 'admin.plugins.perm.oauth:client': 'Conectarse a un servicio de terceros en tu nombre mediante OAuth gestionado por el host (el host guarda los tokens, el complemento nunca los ve)', + 'admin.plugins.perm.db:write:todos': + 'Crear, editar y eliminar tareas en los viajes que el usuario activo puede editar', + 'admin.plugins.perm.db:write:atlas': + 'Marcar países y regiones como visitados y gestionar la lista de deseos del usuario activo (requiere el complemento Atlas)', + 'admin.plugins.perm.db:write:vacay': + 'Alternar días de vacaciones y festivos de empresa en el plan activo del usuario activo (requiere el complemento Vacay)', + 'admin.plugins.perm.db:write:journal': + 'Crear, editar y eliminar entradas de diario en los diarios que el usuario activo puede editar (requiere el complemento Journey)', + 'admin.plugins.perm.db:write:collections': + 'Crear y editar colecciones y guardar lugares en ellas, con el rol de colección del usuario activo (requiere el complemento Collections)', + 'admin.plugins.perm.db:write:files': + 'Adjuntar archivos a los viajes que el usuario activo puede editar (límite de 10 MB, extensiones bloqueadas rechazadas) y gestionar sus enlaces', + 'admin.plugins.perm.db:write:collab': + 'Publicar notas, encuestas y mensajes de chat en los viajes que el usuario activo puede editar (requiere el complemento Collab)', + 'admin.plugins.perm.db:write:members': + 'Añadir usuarios a los viajes — concede acceso al viaje; protegido por el permiso de gestión de miembros del usuario activo', + 'admin.plugins.perm.notify:send': + 'Enviar una notificación (bandeja de campana + correo/ntfy) al usuario activo o a un viaje al que pertenece — nunca a un destinatario arbitrario', + 'admin.plugins.perm.ai:invoke': + 'Usar el proveedor de IA configurado por el administrador (completado de texto + extracción de documentos) en nombre del usuario activo — el complemento nunca guarda una clave', + 'admin.plugins.perm.oauth:client': + 'Conectarse a un servicio de terceros en tu nombre mediante OAuth gestionado por el host (el host guarda los tokens, el complemento nunca los ve)', 'admin.plugins.perm.db:read:files': 'Leer los archivos de los viajes a los que el usuario activo tiene acceso', - 'admin.plugins.perm.db:write:reservations': 'Crear, editar y eliminar reservas en los viajes que el usuario activo puede editar', - 'admin.plugins.perm.db:write:accommodations': 'Crear, editar y eliminar alojamientos en los viajes que el usuario activo puede editar', - 'admin.plugins.perm.db:read:journal': 'Leer los diarios de viaje del usuario activo (requiere el complemento Journey)', - 'admin.plugins.perm.db:read:atlas': 'Leer los países y regiones visitados por el usuario activo (requiere el complemento Atlas)', + 'admin.plugins.perm.db:write:reservations': + 'Crear, editar y eliminar reservas en los viajes que el usuario activo puede editar', + 'admin.plugins.perm.db:write:accommodations': + 'Crear, editar y eliminar alojamientos en los viajes que el usuario activo puede editar', + 'admin.plugins.perm.db:read:journal': + 'Leer los diarios de viaje del usuario activo (requiere el complemento Journey)', + 'admin.plugins.perm.db:read:atlas': + 'Leer los países y regiones visitados por el usuario activo (requiere el complemento Atlas)', 'admin.plugins.perm.db:read:vacay': 'Leer el plan de vacaciones del usuario activo (requiere el complemento Vacay)', - 'admin.plugins.perm.db:read:daynotes': 'Leer las notas del día de los viajes a los que el usuario activo tiene acceso', - 'admin.plugins.perm.db:read:collections': 'Leer las colecciones de lugares guardados del usuario activo (requiere el complemento Collections)', - 'admin.plugins.perm.db:write:daynotes': 'Crear, editar y eliminar notas del día en los viajes que el usuario activo puede editar', - 'admin.plugins.perm.jobs:run': 'Ejecutar sus tareas en segundo plano de forma programada (sin contexto de usuario: no puede leer datos del usuario)', + 'admin.plugins.perm.db:read:daynotes': + 'Leer las notas del día de los viajes a los que el usuario activo tiene acceso', + 'admin.plugins.perm.db:read:collections': + 'Leer las colecciones de lugares guardados del usuario activo (requiere el complemento Collections)', + 'admin.plugins.perm.db:write:daynotes': + 'Crear, editar y eliminar notas del día en los viajes que el usuario activo puede editar', + 'admin.plugins.perm.jobs:run': + 'Ejecutar sus tareas en segundo plano de forma programada (sin contexto de usuario: no puede leer datos del usuario)', 'admin.plugins.perm.db:write:costs': 'Crear gastos (partidas de presupuesto) en los viajes que el usuario activo puede editar', 'admin.plugins.perm.db:write:places': @@ -283,20 +313,26 @@ const admin: TranslationStrings = { 'Aportar detalles adicionales (reseñas, valoraciones, enlaces) a un lugar', 'admin.plugins.perm.hook:trip-warning-provider': 'Generar advertencias de validación en un viaje (mostradas en el planificador)', - 'admin.plugins.perm.hook:table-contributor': 'Añadir columnas y acciones a las vistas del viaje (reservas, lugares, días)', + 'admin.plugins.perm.hook:table-contributor': + 'Añadir columnas y acciones a las vistas del viaje (reservas, lugares, días)', 'admin.plugins.perm.hook:map-marker-provider': 'Añadir marcadores al mapa del viaje (p. ej. mostrar reservas o POIs)', 'admin.plugins.perm.hook:pdf-section-provider': 'Añadir secciones de texto a la exportación PDF del viaje', 'admin.plugins.perm.hook:atlas-layer-provider': 'Resaltar países en el mapamundi del Atlas (p. ej. listas de deseos o avisos de viaje)', 'admin.plugins.perm.hook:journal-entry-provider': 'Añadir filas adicionales (enlaces, estadísticas) a las entradas del diario', - 'admin.plugins.perm.hook:user-data': 'Borrar o exportar los datos que almacena sobre un usuario (RGPD: borrado de cuenta y solicitudes de datos)', - 'admin.plugins.perm.hook:trip-card-provider': 'Añadir pequeñas insignias (estado, recuentos) a las tarjetas de viaje del panel', + 'admin.plugins.perm.hook:user-data': + 'Borrar o exportar los datos que almacena sobre un usuario (RGPD: borrado de cuenta y solicitudes de datos)', + 'admin.plugins.perm.hook:trip-card-provider': + 'Añadir pequeñas insignias (estado, recuentos) a las tarjetas de viaje del panel', + 'admin.plugins.perm.hook:notification-channel': 'Enviar tus notificaciones a través de un canal adicional', 'admin.plugins.perm.events:subscribe': 'Reaccionar a eventos de actividad del núcleo (solo el nombre del evento y el viaje, nunca el contenido)', 'admin.plugins.perm.http:outbound': 'Realizar solicitudes salientes a sus hosts declarados', - 'admin.plugins.perm.db:read:collab': 'Leer las notas, encuestas y mensajes de chat de los viajes a los que el usuario activo tiene acceso (requiere el complemento Collab)', - 'admin.plugins.perm.db:read:files:content': 'Leer el contenido (bytes) de los archivos de los viajes a los que el usuario activo tiene acceso', + 'admin.plugins.perm.db:read:collab': + 'Leer las notas, encuestas y mensajes de chat de los viajes a los que el usuario activo tiene acceso (requiere el complemento Collab)', + 'admin.plugins.perm.db:read:files:content': + 'Leer el contenido (bytes) de los archivos de los viajes a los que el usuario activo tiene acceso', 'admin.plugins.perm.db:create:trips': 'Crear nuevos viajes propiedad del usuario activo', 'admin.plugins.perm.rates:read': 'Leer los tipos de cambio en caché del host', 'admin.plugins.updateConsentTitle': 'Esta actualización necesita nuevos permisos', @@ -366,6 +402,7 @@ const admin: TranslationStrings = { 'admin.plugins.cap.reservationSlot': 'Detalle de la reserva', 'admin.plugins.cap.replacesTabs': 'Reemplaza pestañas del planificador', 'admin.plugins.cap.realtime': 'Actualizaciones en tiempo real', + 'admin.plugins.cap.notificationChannel': 'Canal de notificaciones', 'admin.plugins.cap.photos': 'Proporciona fotos', 'admin.plugins.cap.calendar': 'Proporciona eventos de calendario', 'admin.plugins.cap.placeDetails': 'Enriquece lugares', diff --git a/shared/src/i18n/es/settings.ts b/shared/src/i18n/es/settings.ts index a62fd53b0..278ac1c0d 100644 --- a/shared/src/i18n/es/settings.ts +++ b/shared/src/i18n/es/settings.ts @@ -291,6 +291,14 @@ const settings: TranslationStrings = { 'settings.ntfyUrl.testFailed': 'Error en la notificación de prueba de Ntfy', 'settings.ntfyUrl.tokenCleared': 'Token de acceso eliminado', 'settings.notificationPreferences.inapp': 'In-App', + 'settings.notificationPreferences.notConfigured': 'Aún no configurado — configúralo en los ajustes del plugin', + 'settings.plugins.actions': 'Acciones', + 'settings.plugins.actions.confirm': '¿Ejecutar esta acción?', + 'settings.notificationPreferences.sendTest': 'Enviar prueba', + 'settings.notificationPreferences.configure': 'Configurar', + 'settings.notificationPreferences.testSuccess': 'Notificación de prueba enviada.', + 'settings.notificationPreferences.testFailed': 'La prueba falló.', + 'settings.notificationPreferences.pluginConfigured': 'Configurado. Gestiona tus credenciales en la página de ajustes del plugin.', 'settings.notificationPreferences.webhook': 'Webhook', 'settings.notificationPreferences.email': 'Email', 'settings.notificationPreferences.ntfy': 'Ntfy', diff --git a/shared/src/i18n/fr/admin.ts b/shared/src/i18n/fr/admin.ts index 72e330761..d41fe6fe6 100644 --- a/shared/src/i18n/fr/admin.ts +++ b/shared/src/i18n/fr/admin.ts @@ -170,7 +170,8 @@ const admin: TranslationStrings = { 'admin.plugins.uploaded': "Plugin “{name}” téléversé — activez-le pour l'exécuter", 'admin.plugins.sideloaded': 'Chargé manuellement', 'admin.plugins.devLinkBadge': 'Dev-Link', - 'admin.plugins.devLinkHint': 'Chargé depuis un dossier de build local et rechargé à chaud avec des données réelles — dev uniquement', + 'admin.plugins.devLinkHint': + 'Chargé depuis un dossier de build local et rechargé à chaud avec des données réelles — dev uniquement', 'admin.plugins.devLinkTitle': 'Lier un plugin local', 'admin.plugins.devLinkPathPlaceholder': '/absolute/path/to/your/plugin', 'admin.plugins.devLinkButton': 'Lier', @@ -187,6 +188,15 @@ const admin: TranslationStrings = { 'admin.plugins.deactivated': 'Plugin désactivé', 'admin.plugins.viewErrors': "Voir le journal d'erreurs", 'admin.plugins.errorLog': "Journal d'erreurs", + 'admin.plugins.allowedHosts': 'Hôtes autorisés', + 'admin.plugins.allowedHosts.hint': 'Ce plugin communique avec un service que vous seul pouvez nommer (un serveur auto-hébergé). Ajoutez les hôtes qu’il peut joindre — il ne joindra aucun autre.', + 'admin.plugins.allowedHosts.none': 'Aucun hôte ajouté pour l’instant.', + 'admin.plugins.allowedHosts.unsupported': 'Ce plugin n’utilise pas d’hôtes fournis par l’opérateur. Ses hôtes autorisés sont fixés dans son manifeste.', + 'admin.plugins.allowedHosts.restartNote': 'L’enregistrement redémarre le plugin pour qu’il prenne en compte la nouvelle liste.', + 'admin.plugins.allowedHosts.add': 'Ajouter un hôte autorisé', + 'admin.plugins.allowedHosts.count': '{n} hôte(s) autorisé(s)', + 'admin.plugins.operatorEgressPill': '+ hôtes que vous ajoutez', + 'admin.plugins.operatorEgressHint': 'Ce plugin communique avec un service que vous seul pouvez nommer (un serveur auto-hébergé). Après installation, ajoutez les hôtes qu’il peut joindre via ⋯ → Hôtes autorisés. Il ne joindra aucun autre.', 'admin.plugins.noErrors': 'Aucune erreur enregistrée.', 'admin.plugins.uninstalled': 'Plugin désinstallé', 'admin.plugins.uninstallTitle': 'Désinstaller le plugin ?', @@ -237,33 +247,52 @@ const admin: TranslationStrings = { 'admin.plugins.perm.db:read:users': 'Lire les informations de profil de base (nom, avatar — jamais les identifiants)', 'admin.plugins.perm.db:read:costs': "Lire les dépenses (postes de budget) accessibles à l'utilisateur actif", 'admin.plugins.perm.db:read:packing': "Lire les listes de bagages des voyages accessibles à l'utilisateur actif", - 'admin.plugins.perm.db:write:packing': "Créer, modifier et supprimer des articles de la liste de bagages sur les voyages que l'utilisateur actif peut modifier", + 'admin.plugins.perm.db:write:packing': + "Créer, modifier et supprimer des articles de la liste de bagages sur les voyages que l'utilisateur actif peut modifier", 'admin.plugins.perm.weather:read': "Lire les prévisions météo en cache de l'hôte (par coordonnées)", 'admin.plugins.perm.db:read:categories': 'Lire la liste globale des catégories de lieux', 'admin.plugins.perm.db:read:tags': "Lire les étiquettes de l'utilisateur actif", 'admin.plugins.perm.db:write:tags': "Créer, modifier et supprimer les étiquettes de l'utilisateur actif", 'admin.plugins.perm.db:read:todos': "Lire les tâches des voyages accessibles à l'utilisateur actif", - 'admin.plugins.perm.db:write:todos': "Créer, modifier et supprimer des tâches sur les voyages que l'utilisateur actif peut modifier", - 'admin.plugins.perm.db:write:atlas': "Marquer des pays et régions comme visités et gérer la liste d'envies de l'utilisateur actif (nécessite le module Atlas)", - 'admin.plugins.perm.db:write:vacay': "Basculer les jours de congé et les fermetures d'entreprise sur le plan actif de l'utilisateur actif (nécessite le module Vacay)", - 'admin.plugins.perm.db:write:journal': "Créer, modifier et supprimer des entrées de journal dans les journaux que l'utilisateur actif peut modifier (nécessite le module Journey)", - 'admin.plugins.perm.db:write:collections': "Créer et modifier des collections et y enregistrer des lieux, avec le rôle de collection de l'utilisateur actif (nécessite le module Collections)", - 'admin.plugins.perm.db:write:files': "Joindre des fichiers aux voyages que l'utilisateur actif peut modifier (limite de 10 Mo, extensions bloquées refusées) et gérer leurs liens", - 'admin.plugins.perm.db:write:collab': "Publier des notes, des sondages et des messages de chat sur les voyages que l'utilisateur actif peut modifier (nécessite le module Collab)", - 'admin.plugins.perm.db:write:members': "Ajouter des utilisateurs aux voyages — accorde l'accès au voyage ; protégé par le droit de gestion des membres de l'utilisateur actif", - 'admin.plugins.perm.notify:send': "Envoyer une notification (boîte de réception + e-mail/ntfy) à l'utilisateur actif ou à un voyage auquel il appartient — jamais à un destinataire arbitraire", - 'admin.plugins.perm.ai:invoke': "Utiliser le fournisseur d'IA configuré par l'administrateur (complétion de texte + extraction de documents) au nom de l'utilisateur actif — le module ne détient jamais de clé", - 'admin.plugins.perm.oauth:client': "Se connecter à un service tiers en votre nom via OAuth géré par l'hôte (l'hôte détient les jetons, le module ne les voit jamais)", + 'admin.plugins.perm.db:write:todos': + "Créer, modifier et supprimer des tâches sur les voyages que l'utilisateur actif peut modifier", + 'admin.plugins.perm.db:write:atlas': + "Marquer des pays et régions comme visités et gérer la liste d'envies de l'utilisateur actif (nécessite le module Atlas)", + 'admin.plugins.perm.db:write:vacay': + "Basculer les jours de congé et les fermetures d'entreprise sur le plan actif de l'utilisateur actif (nécessite le module Vacay)", + 'admin.plugins.perm.db:write:journal': + "Créer, modifier et supprimer des entrées de journal dans les journaux que l'utilisateur actif peut modifier (nécessite le module Journey)", + 'admin.plugins.perm.db:write:collections': + "Créer et modifier des collections et y enregistrer des lieux, avec le rôle de collection de l'utilisateur actif (nécessite le module Collections)", + 'admin.plugins.perm.db:write:files': + "Joindre des fichiers aux voyages que l'utilisateur actif peut modifier (limite de 10 Mo, extensions bloquées refusées) et gérer leurs liens", + 'admin.plugins.perm.db:write:collab': + "Publier des notes, des sondages et des messages de chat sur les voyages que l'utilisateur actif peut modifier (nécessite le module Collab)", + 'admin.plugins.perm.db:write:members': + "Ajouter des utilisateurs aux voyages — accorde l'accès au voyage ; protégé par le droit de gestion des membres de l'utilisateur actif", + 'admin.plugins.perm.notify:send': + "Envoyer une notification (boîte de réception + e-mail/ntfy) à l'utilisateur actif ou à un voyage auquel il appartient — jamais à un destinataire arbitraire", + 'admin.plugins.perm.ai:invoke': + "Utiliser le fournisseur d'IA configuré par l'administrateur (complétion de texte + extraction de documents) au nom de l'utilisateur actif — le module ne détient jamais de clé", + 'admin.plugins.perm.oauth:client': + "Se connecter à un service tiers en votre nom via OAuth géré par l'hôte (l'hôte détient les jetons, le module ne les voit jamais)", 'admin.plugins.perm.db:read:files': "Lire les fichiers des voyages accessibles à l'utilisateur actif", - 'admin.plugins.perm.db:write:reservations': "Créer, modifier et supprimer des réservations sur les voyages que l'utilisateur actif peut modifier", - 'admin.plugins.perm.db:write:accommodations': "Créer, modifier et supprimer des hébergements sur les voyages que l'utilisateur actif peut modifier", - 'admin.plugins.perm.db:read:journal': "Lire les journaux de voyage de l'utilisateur actif (nécessite le module Journey)", - 'admin.plugins.perm.db:read:atlas': "Lire les pays et régions visités par l'utilisateur actif (nécessite le module Atlas)", + 'admin.plugins.perm.db:write:reservations': + "Créer, modifier et supprimer des réservations sur les voyages que l'utilisateur actif peut modifier", + 'admin.plugins.perm.db:write:accommodations': + "Créer, modifier et supprimer des hébergements sur les voyages que l'utilisateur actif peut modifier", + 'admin.plugins.perm.db:read:journal': + "Lire les journaux de voyage de l'utilisateur actif (nécessite le module Journey)", + 'admin.plugins.perm.db:read:atlas': + "Lire les pays et régions visités par l'utilisateur actif (nécessite le module Atlas)", 'admin.plugins.perm.db:read:vacay': "Lire le plan de vacances de l'utilisateur actif (nécessite le module Vacay)", 'admin.plugins.perm.db:read:daynotes': "Lire les notes de journée des voyages accessibles à l'utilisateur actif", - 'admin.plugins.perm.db:read:collections': "Lire les collections de lieux enregistrés de l'utilisateur actif (nécessite le module Collections)", - 'admin.plugins.perm.db:write:daynotes': "Créer, modifier et supprimer des notes de journée sur les voyages que l'utilisateur actif peut modifier", - 'admin.plugins.perm.jobs:run': "Exécuter ses tâches d'arrière-plan selon une planification (sans contexte utilisateur — ne peut pas lire les données utilisateur)", + 'admin.plugins.perm.db:read:collections': + "Lire les collections de lieux enregistrés de l'utilisateur actif (nécessite le module Collections)", + 'admin.plugins.perm.db:write:daynotes': + "Créer, modifier et supprimer des notes de journée sur les voyages que l'utilisateur actif peut modifier", + 'admin.plugins.perm.jobs:run': + "Exécuter ses tâches d'arrière-plan selon une planification (sans contexte utilisateur — ne peut pas lire les données utilisateur)", 'admin.plugins.perm.db:write:costs': "Créer des dépenses (postes de budget) sur les voyages que l'utilisateur actif peut modifier", 'admin.plugins.perm.db:write:places': @@ -283,20 +312,27 @@ const admin: TranslationStrings = { 'admin.plugins.perm.hook:place-detail-provider': 'Ajouter des détails supplémentaires (avis, notes, liens) à un lieu', 'admin.plugins.perm.hook:trip-warning-provider': 'Émettre des avertissements de validation sur un voyage (affichés dans le planificateur)', - 'admin.plugins.perm.hook:table-contributor': 'Ajouter des colonnes et des actions aux vues du voyage (réservations, lieux, jours)', - 'admin.plugins.perm.hook:map-marker-provider': 'Ajouter des marqueurs à la carte du voyage (p. ex. afficher réservations ou POIs)', + 'admin.plugins.perm.hook:table-contributor': + 'Ajouter des colonnes et des actions aux vues du voyage (réservations, lieux, jours)', + 'admin.plugins.perm.hook:map-marker-provider': + 'Ajouter des marqueurs à la carte du voyage (p. ex. afficher réservations ou POIs)', 'admin.plugins.perm.hook:pdf-section-provider': "Ajouter des sections de texte à l'export PDF du voyage", 'admin.plugins.perm.hook:atlas-layer-provider': "Mettre en évidence des pays sur la carte du monde Atlas (p. ex. listes d'envies ou avis aux voyageurs)", 'admin.plugins.perm.hook:journal-entry-provider': 'Ajouter des lignes supplémentaires (liens, statistiques) aux entrées du journal', - 'admin.plugins.perm.hook:user-data': "Effacer ou exporter les données qu'il stocke sur un utilisateur (RGPD : suppression de compte et demandes d'accès)", - 'admin.plugins.perm.hook:trip-card-provider': 'Ajouter de petits badges (statut, compteurs) aux cartes de voyage du tableau de bord', + 'admin.plugins.perm.hook:user-data': + "Effacer ou exporter les données qu'il stocke sur un utilisateur (RGPD : suppression de compte et demandes d'accès)", + 'admin.plugins.perm.hook:trip-card-provider': + 'Ajouter de petits badges (statut, compteurs) aux cartes de voyage du tableau de bord', + 'admin.plugins.perm.hook:notification-channel': 'Envoyer vos notifications via un canal supplémentaire', 'admin.plugins.perm.events:subscribe': 'Réagir aux événements d’activité principaux (nom de l’événement et voyage uniquement, jamais le contenu)', 'admin.plugins.perm.http:outbound': 'Effectuer des requêtes sortantes vers ses hôtes déclarés', - 'admin.plugins.perm.db:read:collab': "Lire les notes, sondages et messages de chat des voyages accessibles à l'utilisateur actif (nécessite le module Collab)", - 'admin.plugins.perm.db:read:files:content': "Lire le contenu (octets) des fichiers des voyages accessibles à l'utilisateur actif", + 'admin.plugins.perm.db:read:collab': + "Lire les notes, sondages et messages de chat des voyages accessibles à l'utilisateur actif (nécessite le module Collab)", + 'admin.plugins.perm.db:read:files:content': + "Lire le contenu (octets) des fichiers des voyages accessibles à l'utilisateur actif", 'admin.plugins.perm.db:create:trips': "Créer de nouveaux voyages appartenant à l'utilisateur actif", 'admin.plugins.perm.rates:read': "Lire les taux de change en cache de l'hôte", 'admin.plugins.updateConsentTitle': 'Cette mise à jour nécessite de nouvelles permissions', @@ -367,6 +403,7 @@ const admin: TranslationStrings = { 'admin.plugins.cap.reservationSlot': 'Détail de la réservation', 'admin.plugins.cap.replacesTabs': 'Remplace des onglets du planificateur', 'admin.plugins.cap.realtime': 'Mises à jour en temps réel', + 'admin.plugins.cap.notificationChannel': 'Canal de notification', 'admin.plugins.cap.photos': 'Fournit des photos', 'admin.plugins.cap.calendar': 'Fournit des événements de calendrier', 'admin.plugins.cap.placeDetails': 'Enrichit les lieux', diff --git a/shared/src/i18n/fr/settings.ts b/shared/src/i18n/fr/settings.ts index 0fd6c1dad..281358329 100644 --- a/shared/src/i18n/fr/settings.ts +++ b/shared/src/i18n/fr/settings.ts @@ -294,6 +294,14 @@ const settings: TranslationStrings = { 'settings.ntfyUrl.testFailed': 'Échec de la notification de test Ntfy', 'settings.ntfyUrl.tokenCleared': "Jeton d'accès effacé", 'settings.notificationPreferences.inapp': 'In-App', + 'settings.notificationPreferences.notConfigured': 'Pas encore configuré — à paramétrer dans les réglages du plugin', + 'settings.plugins.actions': 'Actions', + 'settings.plugins.actions.confirm': 'Exécuter cette action ?', + 'settings.notificationPreferences.sendTest': 'Envoyer un test', + 'settings.notificationPreferences.configure': 'Configurer', + 'settings.notificationPreferences.testSuccess': 'Notification de test envoyée.', + 'settings.notificationPreferences.testFailed': 'Échec du test.', + 'settings.notificationPreferences.pluginConfigured': 'Configuré. Gérez vos identifiants sur la page de réglages du plugin.', 'settings.notificationPreferences.webhook': 'Webhook', 'settings.notificationPreferences.email': 'Email', 'settings.notificationPreferences.ntfy': 'Ntfy', @@ -484,7 +492,8 @@ const settings: TranslationStrings = { 'settings.offline.banner.failed': 'Échec de la synchronisation : {count}', 'settings.offline.banner.conflicts': 'Conflits : {count}', 'settings.pluginActivity.title': 'Activité des plugins', - 'settings.pluginActivity.description': 'Chaque action qu\'un plugin a effectuée en votre nom, la plus récente en premier.', + 'settings.pluginActivity.description': + "Chaque action qu'un plugin a effectuée en votre nom, la plus récente en premier.", 'settings.pluginActivity.empty': 'Aucune activité de plugin pour le moment.', 'settings.pluginActivity.refresh': 'Actualiser', 'settings.pluginActivity.columns.plugin': 'Plugin', diff --git a/shared/src/i18n/gr/admin.ts b/shared/src/i18n/gr/admin.ts index e1cd2f484..55116f859 100644 --- a/shared/src/i18n/gr/admin.ts +++ b/shared/src/i18n/gr/admin.ts @@ -215,7 +215,8 @@ const admin: TranslationStrings = { 'admin.plugins.uploaded': 'Το plugin “{name}” μεταφορτώθηκε — ενεργοποιήστε το για εκτέλεση', 'admin.plugins.sideloaded': 'Φορτώθηκε χειροκίνητα', 'admin.plugins.devLinkBadge': 'Dev-Link', - 'admin.plugins.devLinkHint': 'Φορτώνεται από τοπικό κατάλογο build και επαναφορτώνεται ζωντανά με πραγματικά δεδομένα — μόνο για ανάπτυξη', + 'admin.plugins.devLinkHint': + 'Φορτώνεται από τοπικό κατάλογο build και επαναφορτώνεται ζωντανά με πραγματικά δεδομένα — μόνο για ανάπτυξη', 'admin.plugins.devLinkTitle': 'Σύνδεση τοπικού πρόσθετου', 'admin.plugins.devLinkPathPlaceholder': '/absolute/path/to/your/plugin', 'admin.plugins.devLinkButton': 'Σύνδεση', @@ -232,6 +233,15 @@ const admin: TranslationStrings = { 'admin.plugins.deactivated': 'Το plugin απενεργοποιήθηκε', 'admin.plugins.viewErrors': 'Προβολή αρχείου σφαλμάτων', 'admin.plugins.errorLog': 'Αρχείο σφαλμάτων', + 'admin.plugins.allowedHosts': 'Επιτρεπόμενοι κόμβοι', + 'admin.plugins.allowedHosts.hint': 'Αυτό το πρόσθετο επικοινωνεί με μια υπηρεσία που μόνο εσείς μπορείτε να ονομάσετε (αυτο-φιλοξενούμενος διακομιστής). Προσθέστε τους κόμβους που μπορεί να προσεγγίσει — κανέναν άλλον.', + 'admin.plugins.allowedHosts.none': 'Δεν έχουν προστεθεί κόμβοι ακόμη.', + 'admin.plugins.allowedHosts.unsupported': 'Αυτό το πρόσθετο δεν χρησιμοποιεί κόμβους που ορίζει ο διαχειριστής. Οι επιτρεπόμενοι κόμβοι του είναι σταθεροί στο manifest.', + 'admin.plugins.allowedHosts.restartNote': 'Η αποθήκευση επανεκκινεί το πρόσθετο ώστε να λάβει τη νέα λίστα.', + 'admin.plugins.allowedHosts.add': 'Προσθήκη επιτρεπόμενου κόμβου', + 'admin.plugins.allowedHosts.count': '{n} επιτρεπόμενοι κόμβοι', + 'admin.plugins.operatorEgressPill': '+ κόμβοι που προσθέτετε', + 'admin.plugins.operatorEgressHint': 'Αυτό το πρόσθετο επικοινωνεί με μια υπηρεσία που μόνο εσείς μπορείτε να ονομάσετε (αυτο-φιλοξενούμενος διακομιστής). Μετά την εγκατάσταση προσθέστε τους κόμβους στο ⋯ → Επιτρεπόμενοι κόμβοι. Κανέναν άλλον.', 'admin.plugins.noErrors': 'Δεν έχουν καταγραφεί σφάλματα.', 'admin.plugins.uninstalled': 'Το plugin απεγκαταστάθηκε', 'admin.plugins.uninstallTitle': 'Απεγκατάσταση plugin;', @@ -283,33 +293,55 @@ const admin: TranslationStrings = { 'admin.plugins.perm.db:read:costs': 'Ανάγνωση εξόδων (στοιχεία προϋπολογισμού) στα οποία έχει πρόσβαση ο ενεργός χρήστης', 'admin.plugins.perm.db:read:packing': 'Ανάγνωση λιστών αποσκευών ταξιδιών στα οποία έχει πρόσβαση ο ενεργός χρήστης', - 'admin.plugins.perm.db:write:packing': 'Δημιουργία, επεξεργασία και διαγραφή αντικειμένων λίστας αποσκευών σε ταξίδια που μπορεί να επεξεργαστεί ο ενεργός χρήστης', - 'admin.plugins.perm.weather:read': 'Ανάγνωση της προσωρινά αποθηκευμένης πρόγνωσης καιρού του κεντρικού υπολογιστή (ανά συντεταγμένες)', + 'admin.plugins.perm.db:write:packing': + 'Δημιουργία, επεξεργασία και διαγραφή αντικειμένων λίστας αποσκευών σε ταξίδια που μπορεί να επεξεργαστεί ο ενεργός χρήστης', + 'admin.plugins.perm.weather:read': + 'Ανάγνωση της προσωρινά αποθηκευμένης πρόγνωσης καιρού του κεντρικού υπολογιστή (ανά συντεταγμένες)', 'admin.plugins.perm.db:read:categories': 'Ανάγνωση της γενικής λίστας κατηγοριών τοποθεσιών', 'admin.plugins.perm.db:read:tags': 'Ανάγνωση των ετικετών του ενεργού χρήστη', 'admin.plugins.perm.db:write:tags': 'Δημιουργία, επεξεργασία και διαγραφή των ετικετών του ενεργού χρήστη', 'admin.plugins.perm.db:read:todos': 'Ανάγνωση των εργασιών ταξιδιών στα οποία έχει πρόσβαση ο ενεργός χρήστης', - 'admin.plugins.perm.db:write:todos': 'Δημιουργία, επεξεργασία και διαγραφή εργασιών σε ταξίδια που μπορεί να επεξεργαστεί ο ενεργός χρήστης', - 'admin.plugins.perm.db:write:atlas': 'Επισήμανση χωρών και περιοχών ως επισκεφθέντων και διαχείριση της λίστας επιθυμιών του ενεργού χρήστη (απαιτεί το πρόσθετο Atlas)', - 'admin.plugins.perm.db:write:vacay': 'Εναλλαγή ημερών άδειας και εταιρικών αργιών στο ενεργό πλάνο του ενεργού χρήστη (απαιτεί το πρόσθετο Vacay)', - 'admin.plugins.perm.db:write:journal': 'Δημιουργία, επεξεργασία και διαγραφή καταχωρήσεων σε ημερολόγια που μπορεί να επεξεργαστεί ο ενεργός χρήστης (απαιτεί το πρόσθετο Journey)', - 'admin.plugins.perm.db:write:collections': 'Δημιουργία και επεξεργασία συλλογών και αποθήκευση τοποθεσιών σε αυτές, με τον ρόλο συλλογής του ενεργού χρήστη (απαιτεί το πρόσθετο Collections)', - 'admin.plugins.perm.db:write:files': 'Επισύναψη αρχείων σε ταξίδια που μπορεί να επεξεργαστεί ο ενεργός χρήστης (όριο 10 MB, αποκλεισμένες επεκτάσεις απορρίπτονται) και διαχείριση των συνδέσμων τους', - 'admin.plugins.perm.db:write:collab': 'Δημοσίευση σημειώσεων, ψηφοφοριών και μηνυμάτων συνομιλίας σε ταξίδια που μπορεί να επεξεργαστεί ο ενεργός χρήστης (απαιτεί το πρόσθετο Collab)', - 'admin.plugins.perm.db:write:members': 'Προσθήκη χρηστών σε ταξίδια — παρέχει πρόσβαση στο ταξίδι· προστατεύεται από το δικαίωμα διαχείρισης μελών του ενεργού χρήστη', - 'admin.plugins.perm.notify:send': 'Αποστολή ειδοποίησης (εισερχόμενα κουδουνιού + email/ntfy) στον ενεργό χρήστη ή σε ταξίδι στο οποίο ανήκει — ποτέ σε αυθαίρετο παραλήπτη', - 'admin.plugins.perm.ai:invoke': 'Χρήση του παρόχου AI που ρύθμισε ο διαχειριστής (συμπλήρωση κειμένου + εξαγωγή εγγράφων) εκ μέρους του ενεργού χρήστη — το πρόσθετο δεν κρατά ποτέ κλειδί', - 'admin.plugins.perm.oauth:client': 'Σύνδεση σε υπηρεσία τρίτου εκ μέρους σας μέσω OAuth με διαμεσολάβηση του κεντρικού υπολογιστή (ο κεντρικός υπολογιστής κρατά τα διακριτικά, το πρόσθετο δεν τα βλέπει ποτέ)', + 'admin.plugins.perm.db:write:todos': + 'Δημιουργία, επεξεργασία και διαγραφή εργασιών σε ταξίδια που μπορεί να επεξεργαστεί ο ενεργός χρήστης', + 'admin.plugins.perm.db:write:atlas': + 'Επισήμανση χωρών και περιοχών ως επισκεφθέντων και διαχείριση της λίστας επιθυμιών του ενεργού χρήστη (απαιτεί το πρόσθετο Atlas)', + 'admin.plugins.perm.db:write:vacay': + 'Εναλλαγή ημερών άδειας και εταιρικών αργιών στο ενεργό πλάνο του ενεργού χρήστη (απαιτεί το πρόσθετο Vacay)', + 'admin.plugins.perm.db:write:journal': + 'Δημιουργία, επεξεργασία και διαγραφή καταχωρήσεων σε ημερολόγια που μπορεί να επεξεργαστεί ο ενεργός χρήστης (απαιτεί το πρόσθετο Journey)', + 'admin.plugins.perm.db:write:collections': + 'Δημιουργία και επεξεργασία συλλογών και αποθήκευση τοποθεσιών σε αυτές, με τον ρόλο συλλογής του ενεργού χρήστη (απαιτεί το πρόσθετο Collections)', + 'admin.plugins.perm.db:write:files': + 'Επισύναψη αρχείων σε ταξίδια που μπορεί να επεξεργαστεί ο ενεργός χρήστης (όριο 10 MB, αποκλεισμένες επεκτάσεις απορρίπτονται) και διαχείριση των συνδέσμων τους', + 'admin.plugins.perm.db:write:collab': + 'Δημοσίευση σημειώσεων, ψηφοφοριών και μηνυμάτων συνομιλίας σε ταξίδια που μπορεί να επεξεργαστεί ο ενεργός χρήστης (απαιτεί το πρόσθετο Collab)', + 'admin.plugins.perm.db:write:members': + 'Προσθήκη χρηστών σε ταξίδια — παρέχει πρόσβαση στο ταξίδι· προστατεύεται από το δικαίωμα διαχείρισης μελών του ενεργού χρήστη', + 'admin.plugins.perm.notify:send': + 'Αποστολή ειδοποίησης (εισερχόμενα κουδουνιού + email/ntfy) στον ενεργό χρήστη ή σε ταξίδι στο οποίο ανήκει — ποτέ σε αυθαίρετο παραλήπτη', + 'admin.plugins.perm.ai:invoke': + 'Χρήση του παρόχου AI που ρύθμισε ο διαχειριστής (συμπλήρωση κειμένου + εξαγωγή εγγράφων) εκ μέρους του ενεργού χρήστη — το πρόσθετο δεν κρατά ποτέ κλειδί', + 'admin.plugins.perm.oauth:client': + 'Σύνδεση σε υπηρεσία τρίτου εκ μέρους σας μέσω OAuth με διαμεσολάβηση του κεντρικού υπολογιστή (ο κεντρικός υπολογιστής κρατά τα διακριτικά, το πρόσθετο δεν τα βλέπει ποτέ)', 'admin.plugins.perm.db:read:files': 'Ανάγνωση αρχείων ταξιδιών στα οποία έχει πρόσβαση ο ενεργός χρήστης', - 'admin.plugins.perm.db:write:reservations': 'Δημιουργία, επεξεργασία και διαγραφή κρατήσεων σε ταξίδια που μπορεί να επεξεργαστεί ο ενεργός χρήστης', - 'admin.plugins.perm.db:write:accommodations': 'Δημιουργία, επεξεργασία και διαγραφή καταλυμάτων σε ταξίδια που μπορεί να επεξεργαστεί ο ενεργός χρήστης', - 'admin.plugins.perm.db:read:journal': 'Ανάγνωση των ημερολογίων ταξιδιού του ενεργού χρήστη (απαιτεί το πρόσθετο Journey)', - 'admin.plugins.perm.db:read:atlas': 'Ανάγνωση των χωρών και περιοχών που έχει επισκεφθεί ο ενεργός χρήστης (απαιτεί το πρόσθετο Atlas)', - 'admin.plugins.perm.db:read:vacay': 'Ανάγνωση του προγράμματος διακοπών του ενεργού χρήστη (απαιτεί το πρόσθετο Vacay)', - 'admin.plugins.perm.db:read:daynotes': 'Ανάγνωση των σημειώσεων ημέρας ταξιδιών στα οποία έχει πρόσβαση ο ενεργός χρήστης', - 'admin.plugins.perm.db:read:collections': 'Ανάγνωση των συλλογών αποθηκευμένων τοποθεσιών του ενεργού χρήστη (απαιτεί το πρόσθετο Collections)', - 'admin.plugins.perm.db:write:daynotes': 'Δημιουργία, επεξεργασία και διαγραφή σημειώσεων ημέρας σε ταξίδια που μπορεί να επεξεργαστεί ο ενεργός χρήστης', - 'admin.plugins.perm.jobs:run': 'Εκτέλεση των δηλωμένων εργασιών παρασκηνίου βάσει προγράμματος (χωρίς περιβάλλον χρήστη — δεν μπορεί να διαβάσει δεδομένα χρήστη)', + 'admin.plugins.perm.db:write:reservations': + 'Δημιουργία, επεξεργασία και διαγραφή κρατήσεων σε ταξίδια που μπορεί να επεξεργαστεί ο ενεργός χρήστης', + 'admin.plugins.perm.db:write:accommodations': + 'Δημιουργία, επεξεργασία και διαγραφή καταλυμάτων σε ταξίδια που μπορεί να επεξεργαστεί ο ενεργός χρήστης', + 'admin.plugins.perm.db:read:journal': + 'Ανάγνωση των ημερολογίων ταξιδιού του ενεργού χρήστη (απαιτεί το πρόσθετο Journey)', + 'admin.plugins.perm.db:read:atlas': + 'Ανάγνωση των χωρών και περιοχών που έχει επισκεφθεί ο ενεργός χρήστης (απαιτεί το πρόσθετο Atlas)', + 'admin.plugins.perm.db:read:vacay': + 'Ανάγνωση του προγράμματος διακοπών του ενεργού χρήστη (απαιτεί το πρόσθετο Vacay)', + 'admin.plugins.perm.db:read:daynotes': + 'Ανάγνωση των σημειώσεων ημέρας ταξιδιών στα οποία έχει πρόσβαση ο ενεργός χρήστης', + 'admin.plugins.perm.db:read:collections': + 'Ανάγνωση των συλλογών αποθηκευμένων τοποθεσιών του ενεργού χρήστη (απαιτεί το πρόσθετο Collections)', + 'admin.plugins.perm.db:write:daynotes': + 'Δημιουργία, επεξεργασία και διαγραφή σημειώσεων ημέρας σε ταξίδια που μπορεί να επεξεργαστεί ο ενεργός χρήστης', + 'admin.plugins.perm.jobs:run': + 'Εκτέλεση των δηλωμένων εργασιών παρασκηνίου βάσει προγράμματος (χωρίς περιβάλλον χρήστη — δεν μπορεί να διαβάσει δεδομένα χρήστη)', 'admin.plugins.perm.db:write:costs': 'Δημιουργία εξόδων (στοιχεία προϋπολογισμού) σε ταξίδια που μπορεί να επεξεργαστεί ο ενεργός χρήστης', 'admin.plugins.perm.db:write:places': @@ -330,22 +362,30 @@ const admin: TranslationStrings = { 'Συνεισφορά επιπλέον λεπτομερειών (κριτικές, βαθμολογίες, σύνδεσμοι) σε ένα μέρος', 'admin.plugins.perm.hook:trip-warning-provider': 'Εμφάνιση προειδοποιήσεων επικύρωσης σε ένα ταξίδι (εμφανίζονται στον σχεδιαστή)', - 'admin.plugins.perm.hook:table-contributor': 'Προσθήκη στηλών και ενεργειών στις προβολές του ταξιδιού (κρατήσεις, τοποθεσίες, ημέρες)', - 'admin.plugins.perm.hook:map-marker-provider': 'Προσθήκη δεικτών στον χάρτη του ταξιδιού (π.χ. εμφάνιση κρατήσεων ή POI)', + 'admin.plugins.perm.hook:table-contributor': + 'Προσθήκη στηλών και ενεργειών στις προβολές του ταξιδιού (κρατήσεις, τοποθεσίες, ημέρες)', + 'admin.plugins.perm.hook:map-marker-provider': + 'Προσθήκη δεικτών στον χάρτη του ταξιδιού (π.χ. εμφάνιση κρατήσεων ή POI)', 'admin.plugins.perm.hook:pdf-section-provider': 'Προσθήκη ενοτήτων κειμένου στην εξαγωγή PDF του ταξιδιού', 'admin.plugins.perm.hook:atlas-layer-provider': 'Επισήμανση χωρών στον παγκόσμιο χάρτη του Άτλαντα (π.χ. λίστες επιθυμιών ή ταξιδιωτικές οδηγίες)', 'admin.plugins.perm.hook:journal-entry-provider': 'Προσθήκη επιπλέον γραμμών (σύνδεσμοι, στατιστικά) στις καταχωρίσεις του ημερολογίου', - 'admin.plugins.perm.hook:user-data': 'Διαγραφή ή εξαγωγή των δεδομένων που αποθηκεύει για έναν χρήστη (GDPR: διαγραφή λογαριασμού και αιτήματα δεδομένων)', - 'admin.plugins.perm.hook:trip-card-provider': 'Προσθήκη μικρών σημάτων (κατάσταση, μετρήσεις) στις κάρτες ταξιδιού του πίνακα', + 'admin.plugins.perm.hook:user-data': + 'Διαγραφή ή εξαγωγή των δεδομένων που αποθηκεύει για έναν χρήστη (GDPR: διαγραφή λογαριασμού και αιτήματα δεδομένων)', + 'admin.plugins.perm.hook:trip-card-provider': + 'Προσθήκη μικρών σημάτων (κατάσταση, μετρήσεις) στις κάρτες ταξιδιού του πίνακα', + 'admin.plugins.perm.hook:notification-channel': 'Παράδοση των ειδοποιήσεών σας μέσω πρόσθετου καναλιού', 'admin.plugins.perm.events:subscribe': 'Αντιδρά σε βασικά συμβάντα δραστηριότητας (όνομα συμβάντος + ταξίδι μόνο, ποτέ το περιεχόμενο)', 'admin.plugins.perm.http:outbound': 'Εκτέλεση εξερχόμενων αιτημάτων προς τους δηλωμένους hosts του', - 'admin.plugins.perm.db:read:collab': 'Ανάγνωση σημειώσεων, ψηφοφοριών και μηνυμάτων συνομιλίας ταξιδιών στα οποία έχει πρόσβαση ο ενεργός χρήστης (απαιτεί το πρόσθετο Collab)', - 'admin.plugins.perm.db:read:files:content': 'Ανάγνωση του περιεχομένου (bytes) των αρχείων ταξιδιών στα οποία έχει πρόσβαση ο ενεργός χρήστης', + 'admin.plugins.perm.db:read:collab': + 'Ανάγνωση σημειώσεων, ψηφοφοριών και μηνυμάτων συνομιλίας ταξιδιών στα οποία έχει πρόσβαση ο ενεργός χρήστης (απαιτεί το πρόσθετο Collab)', + 'admin.plugins.perm.db:read:files:content': + 'Ανάγνωση του περιεχομένου (bytes) των αρχείων ταξιδιών στα οποία έχει πρόσβαση ο ενεργός χρήστης', 'admin.plugins.perm.db:create:trips': 'Δημιουργία νέων ταξιδιών που ανήκουν στον ενεργό χρήστη', - 'admin.plugins.perm.rates:read': 'Ανάγνωση των προσωρινά αποθηκευμένων συναλλαγματικών ισοτιμιών του κεντρικού υπολογιστή', + 'admin.plugins.perm.rates:read': + 'Ανάγνωση των προσωρινά αποθηκευμένων συναλλαγματικών ισοτιμιών του κεντρικού υπολογιστή', 'admin.plugins.updateConsentTitle': 'Αυτή η ενημέρωση χρειάζεται νέα δικαιώματα', 'admin.plugins.updateConsentBody': 'Το {name} v{version} ζητά δικαιώματα που δεν έχετε ακόμη παραχωρήσει. Η νέα έκδοση έχει εγκατασταθεί αλλά παραμένει ανενεργή μέχρι να την εγκρίνετε.', @@ -413,6 +453,7 @@ const admin: TranslationStrings = { 'admin.plugins.cap.reservationSlot': 'Λεπτομέρειες κράτησης', 'admin.plugins.cap.replacesTabs': 'Αντικαθιστά καρτέλες του σχεδιαστή', 'admin.plugins.cap.realtime': 'Ενημερώσεις σε πραγματικό χρόνο', + 'admin.plugins.cap.notificationChannel': 'Κανάλι ειδοποιήσεων', 'admin.plugins.cap.photos': 'Παρέχει φωτογραφίες', 'admin.plugins.cap.calendar': 'Παρέχει συμβάντα ημερολογίου', 'admin.plugins.cap.placeDetails': 'Εμπλουτίζει μέρη', diff --git a/shared/src/i18n/gr/settings.ts b/shared/src/i18n/gr/settings.ts index be2ab0d34..90b8c6832 100644 --- a/shared/src/i18n/gr/settings.ts +++ b/shared/src/i18n/gr/settings.ts @@ -13,7 +13,8 @@ const settings: TranslationStrings = { 'settings.plugins.oauth.disconnect': 'Αποσύνδεση', 'settings.tabs.plugins': 'Πρόσθετα', 'settings.plugins.title': 'Ρυθμίσεις προσθέτων', - 'settings.plugins.subtitle': 'Οι προσωπικές σας ρυθμίσεις για τα πρόσθετα που χρησιμοποιείτε (κλειδιά API, προτιμήσεις).', + 'settings.plugins.subtitle': + 'Οι προσωπικές σας ρυθμίσεις για τα πρόσθετα που χρησιμοποιείτε (κλειδιά API, προτιμήσεις).', 'settings.plugins.empty': 'Δεν υπάρχουν ενεργά πρόσθετα.', 'settings.plugins.saved': 'Οι ρυθμίσεις αποθηκεύτηκαν', 'settings.tabs.account': 'Λογαριασμός', @@ -95,6 +96,15 @@ const settings: TranslationStrings = { 'settings.notificationPreferences.email': 'Email', 'settings.notificationPreferences.webhook': 'Webhook', 'settings.notificationPreferences.inapp': 'Εντός εφαρμογής', + 'settings.notificationPreferences.notConfigured': + 'Δεν έχει ρυθμιστεί ακόμη — ρυθμίστε το στις ρυθμίσεις του πρόσθετου', + 'settings.plugins.actions': 'Ενέργειες', + 'settings.plugins.actions.confirm': 'Εκτέλεση αυτής της ενέργειας;', + 'settings.notificationPreferences.sendTest': 'Αποστολή δοκιμής', + 'settings.notificationPreferences.configure': 'Ρύθμιση', + 'settings.notificationPreferences.testSuccess': 'Στάλθηκε δοκιμαστική ειδοποίηση.', + 'settings.notificationPreferences.testFailed': 'Η δοκιμή απέτυχε.', + 'settings.notificationPreferences.pluginConfigured': 'Ρυθμίστηκε. Διαχειριστείτε τα διαπιστευτήρια στη σελίδα ρυθμίσεων του πρόσθετου.', 'settings.notificationPreferences.ntfy': 'Ntfy', 'settings.notificationPreferences.noChannels': 'Δεν έχουν διαμορφωθεί κανάλια ειδοποιήσεων. Ζητήστε από έναν διαχειριστή να ρυθμίσει ειδοποιήσεις email ή webhook.', @@ -485,7 +495,8 @@ const settings: TranslationStrings = { 'settings.offline.banner.failed': 'Αποτυχία συγχρονισμού: {count}', 'settings.offline.banner.conflicts': 'Διενέξεις: {count}', 'settings.pluginActivity.title': 'Δραστηριότητα προσθέτων', - 'settings.pluginActivity.description': 'Κάθε ενέργεια που εκτέλεσε ένα πρόσθετο εκ μέρους σας, με τις πιο πρόσφατες πρώτες.', + 'settings.pluginActivity.description': + 'Κάθε ενέργεια που εκτέλεσε ένα πρόσθετο εκ μέρους σας, με τις πιο πρόσφατες πρώτες.', 'settings.pluginActivity.empty': 'Δεν υπάρχει ακόμη δραστηριότητα προσθέτων.', 'settings.pluginActivity.refresh': 'Ανανέωση', 'settings.pluginActivity.columns.plugin': 'Πρόσθετο', diff --git a/shared/src/i18n/hu/admin.ts b/shared/src/i18n/hu/admin.ts index 1205acd88..eb3d05861 100644 --- a/shared/src/i18n/hu/admin.ts +++ b/shared/src/i18n/hu/admin.ts @@ -169,7 +169,8 @@ const admin: TranslationStrings = { 'admin.plugins.uploaded': 'A(z) “{name}” plugin feltöltve — aktiváld a futtatáshoz', 'admin.plugins.sideloaded': 'Manuálisan betöltve', 'admin.plugins.devLinkBadge': 'Dev-Link', - 'admin.plugins.devLinkHint': 'Helyi build könyvtárból betöltve és valós adatokkal élőben újratöltve — csak fejlesztéshez', + 'admin.plugins.devLinkHint': + 'Helyi build könyvtárból betöltve és valós adatokkal élőben újratöltve — csak fejlesztéshez', 'admin.plugins.devLinkTitle': 'Helyi bővítmény csatolása', 'admin.plugins.devLinkPathPlaceholder': '/absolute/path/to/your/plugin', 'admin.plugins.devLinkButton': 'Csatolás', @@ -186,6 +187,15 @@ const admin: TranslationStrings = { 'admin.plugins.deactivated': 'Plugin deaktiválva', 'admin.plugins.viewErrors': 'Hibanapló megtekintése', 'admin.plugins.errorLog': 'Hibanapló', + 'admin.plugins.allowedHosts': 'Engedélyezett hosztok', + 'admin.plugins.allowedHosts.hint': 'Ez a bővítmény olyan szolgáltatással kommunikál, amelyet csak te tudsz megnevezni (saját üzemeltetésű kiszolgáló). Add meg a hosztokat, amelyeket elérhet — másokat nem.', + 'admin.plugins.allowedHosts.none': 'Még nincs hozzáadott hoszt.', + 'admin.plugins.allowedHosts.unsupported': 'Ez a bővítmény nem használ üzemeltető által megadott hosztokat. Az engedélyezett hosztjai a manifestben rögzítettek.', + 'admin.plugins.allowedHosts.restartNote': 'A mentés újraindítja a bővítményt, hogy átvegye az új listát.', + 'admin.plugins.allowedHosts.add': 'Engedélyezett hoszt hozzáadása', + 'admin.plugins.allowedHosts.count': '{n} engedélyezett hoszt', + 'admin.plugins.operatorEgressPill': '+ általad hozzáadott hosztok', + 'admin.plugins.operatorEgressHint': 'Ez a bővítmény olyan szolgáltatással kommunikál, amelyet csak te tudsz megnevezni (saját üzemeltetésű kiszolgáló). Telepítés után add meg a hosztokat a ⋯ → Engedélyezett hosztok alatt. Másokat nem ér el.', 'admin.plugins.noErrors': 'Nincsenek naplózott hibák.', 'admin.plugins.uninstalled': 'Plugin eltávolítva', 'admin.plugins.uninstallTitle': 'Plugin eltávolítása?', @@ -237,33 +247,54 @@ const admin: TranslationStrings = { 'admin.plugins.perm.db:read:costs': 'Az aktuális felhasználó számára elérhető költségek (költségtételek) olvasása', 'admin.plugins.perm.db:read:packing': 'Az aktuális felhasználó számára elérhető utazások csomagolási listáinak olvasása', - 'admin.plugins.perm.db:write:packing': 'Csomagolási lista tételek létrehozása, szerkesztése és törlése az aktuális felhasználó által szerkeszthető utazásokon', - 'admin.plugins.perm.weather:read': 'A gazdagép gyorsítótárazott időjárás-előrejelzésének olvasása (koordináták szerint)', + 'admin.plugins.perm.db:write:packing': + 'Csomagolási lista tételek létrehozása, szerkesztése és törlése az aktuális felhasználó által szerkeszthető utazásokon', + 'admin.plugins.perm.weather:read': + 'A gazdagép gyorsítótárazott időjárás-előrejelzésének olvasása (koordináták szerint)', 'admin.plugins.perm.db:read:categories': 'A globális helykategória-lista olvasása', 'admin.plugins.perm.db:read:tags': 'Az aktuális felhasználó címkéinek olvasása', 'admin.plugins.perm.db:write:tags': 'Az aktuális felhasználó címkéinek létrehozása, szerkesztése és törlése', 'admin.plugins.perm.db:read:todos': 'Az aktuális felhasználó számára elérhető utazások teendőinek olvasása', - 'admin.plugins.perm.db:write:todos': 'Teendők létrehozása, szerkesztése és törlése az aktuális felhasználó által szerkeszthető utazásokon', - 'admin.plugins.perm.db:write:atlas': 'Országok és régiók meglátogatottként jelölése és az aktuális felhasználó bakancslistájának kezelése (az Atlas bővítmény szükséges)', - 'admin.plugins.perm.db:write:vacay': 'Szabadságnapok és céges ünnepek átkapcsolása az aktuális felhasználó aktív tervén (a Vacay bővítmény szükséges)', - 'admin.plugins.perm.db:write:journal': 'Naplóbejegyzések létrehozása, szerkesztése és törlése az aktuális felhasználó által szerkeszthető naplókban (a Journey bővítmény szükséges)', - 'admin.plugins.perm.db:write:collections': 'Gyűjtemények létrehozása és szerkesztése, helyek mentése beléjük, az aktuális felhasználó gyűjteményszerepével (a Collections bővítmény szükséges)', - 'admin.plugins.perm.db:write:files': 'Fájlok csatolása az aktuális felhasználó által szerkeszthető utazásokhoz (10 MB-os korlát, tiltott kiterjesztések elutasítva), és hivatkozásaik kezelése', - 'admin.plugins.perm.db:write:collab': 'Jegyzetek, szavazások és csevegőüzenetek közzététele az aktuális felhasználó által szerkeszthető utazásokon (a Collab bővítmény szükséges)', - 'admin.plugins.perm.db:write:members': 'Felhasználók hozzáadása utazásokhoz — utazás-hozzáférést ad; az aktuális felhasználó tagkezelési jogával védve', - 'admin.plugins.perm.notify:send': 'Értesítés küldése (csengő postaláda + e-mail/ntfy) az aktuális felhasználónak vagy egy utazásának — soha tetszőleges címzettnek', - 'admin.plugins.perm.ai:invoke': 'Az adminisztrátor által beállított MI-szolgáltató (szövegkiegészítés + dokumentumkinyerés) használata az aktuális felhasználó nevében — a bővítmény soha nem tárol kulcsot', - 'admin.plugins.perm.oauth:client': 'Csatlakozás egy harmadik féltől származó szolgáltatáshoz a nevedben, gazdagép által közvetített OAuth-tal (a tokeneket a gazdagép tárolja, a bővítmény sosem látja)', + 'admin.plugins.perm.db:write:todos': + 'Teendők létrehozása, szerkesztése és törlése az aktuális felhasználó által szerkeszthető utazásokon', + 'admin.plugins.perm.db:write:atlas': + 'Országok és régiók meglátogatottként jelölése és az aktuális felhasználó bakancslistájának kezelése (az Atlas bővítmény szükséges)', + 'admin.plugins.perm.db:write:vacay': + 'Szabadságnapok és céges ünnepek átkapcsolása az aktuális felhasználó aktív tervén (a Vacay bővítmény szükséges)', + 'admin.plugins.perm.db:write:journal': + 'Naplóbejegyzések létrehozása, szerkesztése és törlése az aktuális felhasználó által szerkeszthető naplókban (a Journey bővítmény szükséges)', + 'admin.plugins.perm.db:write:collections': + 'Gyűjtemények létrehozása és szerkesztése, helyek mentése beléjük, az aktuális felhasználó gyűjteményszerepével (a Collections bővítmény szükséges)', + 'admin.plugins.perm.db:write:files': + 'Fájlok csatolása az aktuális felhasználó által szerkeszthető utazásokhoz (10 MB-os korlát, tiltott kiterjesztések elutasítva), és hivatkozásaik kezelése', + 'admin.plugins.perm.db:write:collab': + 'Jegyzetek, szavazások és csevegőüzenetek közzététele az aktuális felhasználó által szerkeszthető utazásokon (a Collab bővítmény szükséges)', + 'admin.plugins.perm.db:write:members': + 'Felhasználók hozzáadása utazásokhoz — utazás-hozzáférést ad; az aktuális felhasználó tagkezelési jogával védve', + 'admin.plugins.perm.notify:send': + 'Értesítés küldése (csengő postaláda + e-mail/ntfy) az aktuális felhasználónak vagy egy utazásának — soha tetszőleges címzettnek', + 'admin.plugins.perm.ai:invoke': + 'Az adminisztrátor által beállított MI-szolgáltató (szövegkiegészítés + dokumentumkinyerés) használata az aktuális felhasználó nevében — a bővítmény soha nem tárol kulcsot', + 'admin.plugins.perm.oauth:client': + 'Csatlakozás egy harmadik féltől származó szolgáltatáshoz a nevedben, gazdagép által közvetített OAuth-tal (a tokeneket a gazdagép tárolja, a bővítmény sosem látja)', 'admin.plugins.perm.db:read:files': 'Az aktuális felhasználó számára elérhető utazások fájljainak olvasása', - 'admin.plugins.perm.db:write:reservations': 'Foglalások létrehozása, szerkesztése és törlése az aktuális felhasználó által szerkeszthető utazásokon', - 'admin.plugins.perm.db:write:accommodations': 'Szállások létrehozása, szerkesztése és törlése az aktuális felhasználó által szerkeszthető utazásokon', - 'admin.plugins.perm.db:read:journal': 'Az aktuális felhasználó úti naplóinak olvasása (a Journey bővítmény szükséges)', - 'admin.plugins.perm.db:read:atlas': 'Az aktuális felhasználó által meglátogatott országok és régiók olvasása (az Atlas bővítmény szükséges)', - 'admin.plugins.perm.db:read:vacay': 'Az aktuális felhasználó nyaralási tervének olvasása (a Vacay bővítmény szükséges)', + 'admin.plugins.perm.db:write:reservations': + 'Foglalások létrehozása, szerkesztése és törlése az aktuális felhasználó által szerkeszthető utazásokon', + 'admin.plugins.perm.db:write:accommodations': + 'Szállások létrehozása, szerkesztése és törlése az aktuális felhasználó által szerkeszthető utazásokon', + 'admin.plugins.perm.db:read:journal': + 'Az aktuális felhasználó úti naplóinak olvasása (a Journey bővítmény szükséges)', + 'admin.plugins.perm.db:read:atlas': + 'Az aktuális felhasználó által meglátogatott országok és régiók olvasása (az Atlas bővítmény szükséges)', + 'admin.plugins.perm.db:read:vacay': + 'Az aktuális felhasználó nyaralási tervének olvasása (a Vacay bővítmény szükséges)', 'admin.plugins.perm.db:read:daynotes': 'Az aktuális felhasználó számára elérhető utazások napi jegyzeteinek olvasása', - 'admin.plugins.perm.db:read:collections': 'Az aktuális felhasználó mentett helyeinek gyűjteményeit olvasása (a Collections bővítmény szükséges)', - 'admin.plugins.perm.db:write:daynotes': 'Napi jegyzetek létrehozása, szerkesztése és törlése az aktuális felhasználó által szerkeszthető utazásokon', - 'admin.plugins.perm.jobs:run': 'A deklarált háttérfeladatok ütemezett futtatása (felhasználói környezet nélkül — nem olvashat felhasználói adatokat)', + 'admin.plugins.perm.db:read:collections': + 'Az aktuális felhasználó mentett helyeinek gyűjteményeit olvasása (a Collections bővítmény szükséges)', + 'admin.plugins.perm.db:write:daynotes': + 'Napi jegyzetek létrehozása, szerkesztése és törlése az aktuális felhasználó által szerkeszthető utazásokon', + 'admin.plugins.perm.jobs:run': + 'A deklarált háttérfeladatok ütemezett futtatása (felhasználói környezet nélkül — nem olvashat felhasználói adatokat)', 'admin.plugins.perm.db:write:costs': 'Költségek (költségtételek) létrehozása az aktuális felhasználó által szerkeszthető utazásokon', 'admin.plugins.perm.db:write:places': @@ -284,20 +315,27 @@ const admin: TranslationStrings = { 'További részletek (értékelések, minősítések, hivatkozások) hozzáadása egy helyhez', 'admin.plugins.perm.hook:trip-warning-provider': 'Ellenőrzési figyelmeztetések megjelenítése egy utazáson (a tervezőben látható)', - 'admin.plugins.perm.hook:table-contributor': 'Oszlopok és műveletek hozzáadása az utazási nézetekhez (foglalások, helyek, napok)', - 'admin.plugins.perm.hook:map-marker-provider': 'Jelölők hozzáadása az utazás térképéhez (pl. foglalások vagy POI-k megjelenítése)', + 'admin.plugins.perm.hook:table-contributor': + 'Oszlopok és műveletek hozzáadása az utazási nézetekhez (foglalások, helyek, napok)', + 'admin.plugins.perm.hook:map-marker-provider': + 'Jelölők hozzáadása az utazás térképéhez (pl. foglalások vagy POI-k megjelenítése)', 'admin.plugins.perm.hook:pdf-section-provider': 'Szöveges szakaszok hozzáfűzése az utazás PDF-exportjához', 'admin.plugins.perm.hook:atlas-layer-provider': 'Országok kiemelése az Atlas világtérképén (pl. kívánságlisták vagy utazási figyelmeztetések)', 'admin.plugins.perm.hook:journal-entry-provider': 'További sorok (hivatkozások, statisztikák) hozzáadása a naplóbejegyzésekhez', - 'admin.plugins.perm.hook:user-data': 'A felhasználóról tárolt adatok törlése vagy exportálása (GDPR: fióktörlés és adatkérések)', - 'admin.plugins.perm.hook:trip-card-provider': 'Kis jelvények (állapot, számlálók) hozzáadása az irányítópult utazáskártyáihoz', + 'admin.plugins.perm.hook:user-data': + 'A felhasználóról tárolt adatok törlése vagy exportálása (GDPR: fióktörlés és adatkérések)', + 'admin.plugins.perm.hook:trip-card-provider': + 'Kis jelvények (állapot, számlálók) hozzáadása az irányítópult utazáskártyáihoz', + 'admin.plugins.perm.hook:notification-channel': 'Értesítéseid kézbesítése egy további csatornán', 'admin.plugins.perm.events:subscribe': 'Reagál az alapvető tevékenységi eseményekre (csak eseménynév + utazás, soha nem a tartalom)', 'admin.plugins.perm.http:outbound': 'Kimenő kérések küldése a bejelentett kiszolgálók felé', - 'admin.plugins.perm.db:read:collab': 'Jegyzetek, szavazások és csevegőüzenetek olvasása az aktuális felhasználó számára elérhető utazásokon (a Collab bővítmény szükséges)', - 'admin.plugins.perm.db:read:files:content': 'Az aktuális felhasználó számára elérhető utazások fájltartalmának (bájtok) olvasása', + 'admin.plugins.perm.db:read:collab': + 'Jegyzetek, szavazások és csevegőüzenetek olvasása az aktuális felhasználó számára elérhető utazásokon (a Collab bővítmény szükséges)', + 'admin.plugins.perm.db:read:files:content': + 'Az aktuális felhasználó számára elérhető utazások fájltartalmának (bájtok) olvasása', 'admin.plugins.perm.db:create:trips': 'Új utazások létrehozása az aktuális felhasználó tulajdonában', 'admin.plugins.perm.rates:read': 'A gazdagép gyorsítótárazott devizaárfolyamainak olvasása', 'admin.plugins.updateConsentTitle': 'Ez a frissítés új engedélyeket igényel', @@ -367,6 +405,7 @@ const admin: TranslationStrings = { 'admin.plugins.cap.reservationSlot': 'Foglalás részletei', 'admin.plugins.cap.replacesTabs': 'Tervező lapokat vált ki', 'admin.plugins.cap.realtime': 'Valós idejű frissítések', + 'admin.plugins.cap.notificationChannel': 'Értesítési csatorna', 'admin.plugins.cap.photos': 'Fotókat biztosít', 'admin.plugins.cap.calendar': 'Naptáreseményeket biztosít', 'admin.plugins.cap.placeDetails': 'Helyeket gazdagít', diff --git a/shared/src/i18n/hu/settings.ts b/shared/src/i18n/hu/settings.ts index ce712aa76..6f111d2ee 100644 --- a/shared/src/i18n/hu/settings.ts +++ b/shared/src/i18n/hu/settings.ts @@ -290,6 +290,14 @@ const settings: TranslationStrings = { 'settings.ntfyUrl.testFailed': 'Teszt Ntfy értesítés sikertelen', 'settings.ntfyUrl.tokenCleared': 'Hozzáférési token törölve', 'settings.notificationPreferences.inapp': 'In-App', + 'settings.notificationPreferences.notConfigured': 'Még nincs beállítva — állítsd be a bővítmény beállításaiban', + 'settings.plugins.actions': 'Műveletek', + 'settings.plugins.actions.confirm': 'Végrehajtod ezt a műveletet?', + 'settings.notificationPreferences.sendTest': 'Teszt küldése', + 'settings.notificationPreferences.configure': 'Beállítás', + 'settings.notificationPreferences.testSuccess': 'Teszt értesítés elküldve.', + 'settings.notificationPreferences.testFailed': 'A teszt sikertelen.', + 'settings.notificationPreferences.pluginConfigured': 'Beállítva. A hitelesítő adatok a bővítmény beállításainál kezelhetők.', 'settings.notificationPreferences.webhook': 'Webhook', 'settings.notificationPreferences.email': 'Email', 'settings.notificationPreferences.ntfy': 'Ntfy', @@ -477,7 +485,8 @@ const settings: TranslationStrings = { 'settings.offline.banner.failed': 'Sikertelen szinkronizálás: {count}', 'settings.offline.banner.conflicts': 'Ütközések: {count}', 'settings.pluginActivity.title': 'Bővítmény-tevékenység', - 'settings.pluginActivity.description': 'Minden művelet, amelyet egy bővítmény a nevedben végzett, a legújabbtól kezdve.', + 'settings.pluginActivity.description': + 'Minden művelet, amelyet egy bővítmény a nevedben végzett, a legújabbtól kezdve.', 'settings.pluginActivity.empty': 'Még nincs bővítmény-tevékenység.', 'settings.pluginActivity.refresh': 'Frissítés', 'settings.pluginActivity.columns.plugin': 'Bővítmény', diff --git a/shared/src/i18n/id/admin.ts b/shared/src/i18n/id/admin.ts index 685fc8164..42a84f281 100644 --- a/shared/src/i18n/id/admin.ts +++ b/shared/src/i18n/id/admin.ts @@ -211,7 +211,8 @@ const admin: TranslationStrings = { 'admin.plugins.uploaded': 'Plugin “{name}” diunggah — aktifkan untuk menjalankan', 'admin.plugins.sideloaded': 'Dimuat manual', 'admin.plugins.devLinkBadge': 'Dev-Link', - 'admin.plugins.devLinkHint': 'Dimuat dari direktori build lokal dan dimuat ulang langsung dengan data nyata — hanya untuk pengembangan', + 'admin.plugins.devLinkHint': + 'Dimuat dari direktori build lokal dan dimuat ulang langsung dengan data nyata — hanya untuk pengembangan', 'admin.plugins.devLinkTitle': 'Tautkan plugin lokal', 'admin.plugins.devLinkPathPlaceholder': '/absolute/path/to/your/plugin', 'admin.plugins.devLinkButton': 'Tautkan', @@ -228,6 +229,15 @@ const admin: TranslationStrings = { 'admin.plugins.deactivated': 'Plugin dinonaktifkan', 'admin.plugins.viewErrors': 'Lihat log error', 'admin.plugins.errorLog': 'Log error', + 'admin.plugins.allowedHosts': 'Host yang diizinkan', + 'admin.plugins.allowedHosts.hint': 'Plugin ini berbicara dengan layanan yang hanya Anda yang tahu namanya (server swakelola). Tambahkan host yang boleh dijangkau — selain itu tidak bisa.', + 'admin.plugins.allowedHosts.none': 'Belum ada host yang ditambahkan.', + 'admin.plugins.allowedHosts.unsupported': 'Plugin ini tidak memakai host dari operator. Host yang diizinkan sudah tetap di manifesnya.', + 'admin.plugins.allowedHosts.restartNote': 'Menyimpan akan memulai ulang plugin agar memakai daftar baru.', + 'admin.plugins.allowedHosts.add': 'Tambah host yang diizinkan', + 'admin.plugins.allowedHosts.count': '{n} host diizinkan', + 'admin.plugins.operatorEgressPill': '+ host yang Anda tambahkan', + 'admin.plugins.operatorEgressHint': 'Plugin ini berbicara dengan layanan yang hanya Anda yang tahu namanya (server swakelola). Setelah dipasang, tambahkan host di ⋯ → Host yang diizinkan. Selain itu tidak bisa.', 'admin.plugins.noErrors': 'Tidak ada error yang tercatat.', 'admin.plugins.uninstalled': 'Plugin dihapus', 'admin.plugins.uninstallTitle': 'Hapus plugin?', @@ -280,34 +290,55 @@ const admin: TranslationStrings = { 'Membaca pengeluaran (item anggaran) yang dapat diakses oleh pengguna yang bersangkutan', 'admin.plugins.perm.db:read:packing': 'Membaca daftar barang bawaan dari perjalanan yang dapat diakses oleh pengguna yang bersangkutan', - 'admin.plugins.perm.db:write:packing': 'Membuat, mengedit, dan menghapus item daftar bawaan pada perjalanan yang dapat diedit oleh pengguna yang bersangkutan', + 'admin.plugins.perm.db:write:packing': + 'Membuat, mengedit, dan menghapus item daftar bawaan pada perjalanan yang dapat diedit oleh pengguna yang bersangkutan', 'admin.plugins.perm.weather:read': 'Membaca prakiraan cuaca host yang di-cache (berdasarkan koordinat)', 'admin.plugins.perm.db:read:categories': 'Membaca daftar kategori tempat global', 'admin.plugins.perm.db:read:tags': 'Membaca tag pengguna yang bersangkutan', 'admin.plugins.perm.db:write:tags': 'Membuat, mengedit, dan menghapus tag pengguna yang bersangkutan', - 'admin.plugins.perm.db:read:todos': 'Membaca tugas dari perjalanan yang dapat diakses oleh pengguna yang bersangkutan', - 'admin.plugins.perm.db:write:todos': 'Membuat, mengedit, dan menghapus tugas pada perjalanan yang dapat diedit oleh pengguna yang bersangkutan', - 'admin.plugins.perm.db:write:atlas': 'Menandai negara dan wilayah sebagai sudah dikunjungi dan mengelola bucket list pengguna yang bersangkutan (memerlukan add-on Atlas)', - 'admin.plugins.perm.db:write:vacay': 'Mengalihkan hari cuti dan libur perusahaan pada rencana aktif pengguna yang bersangkutan (memerlukan add-on Vacay)', - 'admin.plugins.perm.db:write:journal': 'Membuat, mengedit, dan menghapus entri jurnal di jurnal yang dapat diedit oleh pengguna yang bersangkutan (memerlukan add-on Journey)', - 'admin.plugins.perm.db:write:collections': 'Membuat dan mengedit koleksi serta menyimpan tempat ke dalamnya, dengan peran koleksi pengguna yang bersangkutan (memerlukan add-on Collections)', - 'admin.plugins.perm.db:write:files': 'Melampirkan berkas ke perjalanan yang dapat diedit oleh pengguna yang bersangkutan (batas 10 MB, ekstensi terblokir ditolak) dan mengelola tautannya', - 'admin.plugins.perm.db:write:collab': 'Memublikasikan catatan, jajak pendapat, dan pesan obrolan pada perjalanan yang dapat diedit oleh pengguna yang bersangkutan (memerlukan add-on Collab)', - 'admin.plugins.perm.db:write:members': 'Menambahkan pengguna ke perjalanan — memberikan akses perjalanan; dilindungi oleh hak kelola anggota pengguna yang bersangkutan', - 'admin.plugins.perm.notify:send': 'Mengirim notifikasi (kotak lonceng + email/ntfy) kepada pengguna yang bersangkutan atau perjalanan yang diikutinya — tidak pernah ke penerima sembarangan', - 'admin.plugins.perm.ai:invoke': 'Menjalankan penyedia AI yang dikonfigurasi admin (penyelesaian teks + ekstraksi dokumen) atas nama pengguna yang bersangkutan — plugin tidak pernah menyimpan kunci', - 'admin.plugins.perm.oauth:client': 'Terhubung ke layanan pihak ketiga atas namamu melalui OAuth yang diperantarai host (host menyimpan token, plugin tidak pernah melihatnya)', + 'admin.plugins.perm.db:read:todos': + 'Membaca tugas dari perjalanan yang dapat diakses oleh pengguna yang bersangkutan', + 'admin.plugins.perm.db:write:todos': + 'Membuat, mengedit, dan menghapus tugas pada perjalanan yang dapat diedit oleh pengguna yang bersangkutan', + 'admin.plugins.perm.db:write:atlas': + 'Menandai negara dan wilayah sebagai sudah dikunjungi dan mengelola bucket list pengguna yang bersangkutan (memerlukan add-on Atlas)', + 'admin.plugins.perm.db:write:vacay': + 'Mengalihkan hari cuti dan libur perusahaan pada rencana aktif pengguna yang bersangkutan (memerlukan add-on Vacay)', + 'admin.plugins.perm.db:write:journal': + 'Membuat, mengedit, dan menghapus entri jurnal di jurnal yang dapat diedit oleh pengguna yang bersangkutan (memerlukan add-on Journey)', + 'admin.plugins.perm.db:write:collections': + 'Membuat dan mengedit koleksi serta menyimpan tempat ke dalamnya, dengan peran koleksi pengguna yang bersangkutan (memerlukan add-on Collections)', + 'admin.plugins.perm.db:write:files': + 'Melampirkan berkas ke perjalanan yang dapat diedit oleh pengguna yang bersangkutan (batas 10 MB, ekstensi terblokir ditolak) dan mengelola tautannya', + 'admin.plugins.perm.db:write:collab': + 'Memublikasikan catatan, jajak pendapat, dan pesan obrolan pada perjalanan yang dapat diedit oleh pengguna yang bersangkutan (memerlukan add-on Collab)', + 'admin.plugins.perm.db:write:members': + 'Menambahkan pengguna ke perjalanan — memberikan akses perjalanan; dilindungi oleh hak kelola anggota pengguna yang bersangkutan', + 'admin.plugins.perm.notify:send': + 'Mengirim notifikasi (kotak lonceng + email/ntfy) kepada pengguna yang bersangkutan atau perjalanan yang diikutinya — tidak pernah ke penerima sembarangan', + 'admin.plugins.perm.ai:invoke': + 'Menjalankan penyedia AI yang dikonfigurasi admin (penyelesaian teks + ekstraksi dokumen) atas nama pengguna yang bersangkutan — plugin tidak pernah menyimpan kunci', + 'admin.plugins.perm.oauth:client': + 'Terhubung ke layanan pihak ketiga atas namamu melalui OAuth yang diperantarai host (host menyimpan token, plugin tidak pernah melihatnya)', 'admin.plugins.perm.db:read:files': 'Membaca berkas dari perjalanan yang dapat diakses oleh pengguna yang bersangkutan', - 'admin.plugins.perm.db:write:reservations': 'Membuat, mengedit, dan menghapus pemesanan pada perjalanan yang dapat diedit oleh pengguna yang bersangkutan', - 'admin.plugins.perm.db:write:accommodations': 'Membuat, mengedit, dan menghapus akomodasi pada perjalanan yang dapat diedit oleh pengguna yang bersangkutan', - 'admin.plugins.perm.db:read:journal': 'Membaca jurnal perjalanan pengguna yang bersangkutan (memerlukan add-on Journey)', - 'admin.plugins.perm.db:read:atlas': 'Membaca negara dan wilayah yang dikunjungi oleh pengguna yang bersangkutan (memerlukan add-on Atlas)', + 'admin.plugins.perm.db:write:reservations': + 'Membuat, mengedit, dan menghapus pemesanan pada perjalanan yang dapat diedit oleh pengguna yang bersangkutan', + 'admin.plugins.perm.db:write:accommodations': + 'Membuat, mengedit, dan menghapus akomodasi pada perjalanan yang dapat diedit oleh pengguna yang bersangkutan', + 'admin.plugins.perm.db:read:journal': + 'Membaca jurnal perjalanan pengguna yang bersangkutan (memerlukan add-on Journey)', + 'admin.plugins.perm.db:read:atlas': + 'Membaca negara dan wilayah yang dikunjungi oleh pengguna yang bersangkutan (memerlukan add-on Atlas)', 'admin.plugins.perm.db:read:vacay': 'Membaca rencana liburan pengguna yang bersangkutan (memerlukan add-on Vacay)', - 'admin.plugins.perm.db:read:daynotes': 'Membaca catatan harian dari perjalanan yang dapat diakses oleh pengguna yang bersangkutan', - 'admin.plugins.perm.db:read:collections': 'Membaca koleksi tempat tersimpan milik pengguna yang bersangkutan (memerlukan add-on Collections)', - 'admin.plugins.perm.db:write:daynotes': 'Membuat, mengedit, dan menghapus catatan harian pada perjalanan yang dapat diedit oleh pengguna yang bersangkutan', - 'admin.plugins.perm.jobs:run': 'Menjalankan tugas latar belakang yang dideklarasikan sesuai jadwal (tanpa konteks pengguna — tidak dapat membaca data pengguna)', + 'admin.plugins.perm.db:read:daynotes': + 'Membaca catatan harian dari perjalanan yang dapat diakses oleh pengguna yang bersangkutan', + 'admin.plugins.perm.db:read:collections': + 'Membaca koleksi tempat tersimpan milik pengguna yang bersangkutan (memerlukan add-on Collections)', + 'admin.plugins.perm.db:write:daynotes': + 'Membuat, mengedit, dan menghapus catatan harian pada perjalanan yang dapat diedit oleh pengguna yang bersangkutan', + 'admin.plugins.perm.jobs:run': + 'Menjalankan tugas latar belakang yang dideklarasikan sesuai jadwal (tanpa konteks pengguna — tidak dapat membaca data pengguna)', 'admin.plugins.perm.db:write:costs': 'Membuat pengeluaran (item anggaran) pada perjalanan yang dapat diedit oleh pengguna yang bersangkutan', 'admin.plugins.perm.db:write:places': @@ -328,19 +359,25 @@ const admin: TranslationStrings = { 'Menyumbangkan detail tambahan (ulasan, peringkat, tautan) untuk suatu tempat', 'admin.plugins.perm.hook:trip-warning-provider': 'Munculkan peringatan validasi pada perjalanan (ditampilkan di perencana)', - 'admin.plugins.perm.hook:table-contributor': 'Menambahkan kolom dan tindakan ke tampilan perjalanan (pemesanan, tempat, hari)', - 'admin.plugins.perm.hook:map-marker-provider': 'Menambahkan penanda ke peta perjalanan (mis. menampilkan pemesanan atau POI)', + 'admin.plugins.perm.hook:table-contributor': + 'Menambahkan kolom dan tindakan ke tampilan perjalanan (pemesanan, tempat, hari)', + 'admin.plugins.perm.hook:map-marker-provider': + 'Menambahkan penanda ke peta perjalanan (mis. menampilkan pemesanan atau POI)', 'admin.plugins.perm.hook:pdf-section-provider': 'Menambahkan bagian teks ke ekspor PDF perjalanan', 'admin.plugins.perm.hook:atlas-layer-provider': 'Menyorot negara di peta dunia Atlas (mis. daftar keinginan atau peringatan perjalanan)', 'admin.plugins.perm.hook:journal-entry-provider': 'Menambahkan baris tambahan (tautan, statistik) ke entri jurnal', - 'admin.plugins.perm.hook:user-data': 'Menghapus atau mengekspor data yang disimpannya tentang pengguna (GDPR: penghapusan akun & permintaan data)', + 'admin.plugins.perm.hook:user-data': + 'Menghapus atau mengekspor data yang disimpannya tentang pengguna (GDPR: penghapusan akun & permintaan data)', 'admin.plugins.perm.hook:trip-card-provider': 'Menambahkan lencana kecil (status, jumlah) ke kartu perjalanan dasbor', + 'admin.plugins.perm.hook:notification-channel': 'Mengirim notifikasi Anda melalui saluran tambahan', 'admin.plugins.perm.events:subscribe': 'Bereaksi terhadap peristiwa aktivitas inti (hanya nama peristiwa + perjalanan, tidak pernah kontennya)', 'admin.plugins.perm.http:outbound': 'Membuat permintaan keluar ke host yang telah dideklarasikannya', - 'admin.plugins.perm.db:read:collab': 'Membaca catatan, jajak pendapat, dan pesan obrolan dari perjalanan yang dapat diakses oleh pengguna yang bersangkutan (memerlukan add-on Collab)', - 'admin.plugins.perm.db:read:files:content': 'Membaca isi (byte) berkas dari perjalanan yang dapat diakses oleh pengguna yang bersangkutan', + 'admin.plugins.perm.db:read:collab': + 'Membaca catatan, jajak pendapat, dan pesan obrolan dari perjalanan yang dapat diakses oleh pengguna yang bersangkutan (memerlukan add-on Collab)', + 'admin.plugins.perm.db:read:files:content': + 'Membaca isi (byte) berkas dari perjalanan yang dapat diakses oleh pengguna yang bersangkutan', 'admin.plugins.perm.db:create:trips': 'Membuat perjalanan baru milik pengguna yang bersangkutan', 'admin.plugins.perm.rates:read': 'Membaca kurs mata uang host yang di-cache', 'admin.plugins.updateConsentTitle': 'Pembaruan ini memerlukan izin baru', @@ -410,6 +447,7 @@ const admin: TranslationStrings = { 'admin.plugins.cap.reservationSlot': 'Detail reservasi', 'admin.plugins.cap.replacesTabs': 'Menggantikan tab perencana', 'admin.plugins.cap.realtime': 'Pembaruan real-time', + 'admin.plugins.cap.notificationChannel': 'Saluran notifikasi', 'admin.plugins.cap.photos': 'Menyediakan foto', 'admin.plugins.cap.calendar': 'Menyediakan acara kalender', 'admin.plugins.cap.placeDetails': 'Memperkaya tempat', diff --git a/shared/src/i18n/id/settings.ts b/shared/src/i18n/id/settings.ts index 1ced97fdc..afed07c63 100644 --- a/shared/src/i18n/id/settings.ts +++ b/shared/src/i18n/id/settings.ts @@ -88,6 +88,14 @@ const settings: TranslationStrings = { 'settings.notificationPreferences.email': 'Email', 'settings.notificationPreferences.webhook': 'Webhook', 'settings.notificationPreferences.inapp': 'In-App', + 'settings.notificationPreferences.notConfigured': 'Belum dikonfigurasi — atur di pengaturan plugin', + 'settings.plugins.actions': 'Tindakan', + 'settings.plugins.actions.confirm': 'Jalankan tindakan ini?', + 'settings.notificationPreferences.sendTest': 'Kirim tes', + 'settings.notificationPreferences.configure': 'Konfigurasi', + 'settings.notificationPreferences.testSuccess': 'Notifikasi tes terkirim.', + 'settings.notificationPreferences.testFailed': 'Tes gagal.', + 'settings.notificationPreferences.pluginConfigured': 'Sudah diatur. Kelola kredensial di halaman pengaturan plugin.', 'settings.notificationPreferences.ntfy': 'Ntfy', 'settings.notificationPreferences.noChannels': 'Belum ada saluran notifikasi yang dikonfigurasi. Minta admin untuk mengatur notifikasi email atau webhook.', diff --git a/shared/src/i18n/it/admin.ts b/shared/src/i18n/it/admin.ts index 1333b1201..d713450f2 100644 --- a/shared/src/i18n/it/admin.ts +++ b/shared/src/i18n/it/admin.ts @@ -168,7 +168,8 @@ const admin: TranslationStrings = { 'admin.plugins.uploaded': 'Plugin “{name}” caricato — attivalo per eseguirlo', 'admin.plugins.sideloaded': 'Caricato manualmente', 'admin.plugins.devLinkBadge': 'Dev-Link', - 'admin.plugins.devLinkHint': 'Caricato da una cartella di build locale e ricaricato a caldo con dati reali — solo per lo sviluppo', + 'admin.plugins.devLinkHint': + 'Caricato da una cartella di build locale e ricaricato a caldo con dati reali — solo per lo sviluppo', 'admin.plugins.devLinkTitle': 'Collega un plugin locale', 'admin.plugins.devLinkPathPlaceholder': '/absolute/path/to/your/plugin', 'admin.plugins.devLinkButton': 'Collega', @@ -185,6 +186,15 @@ const admin: TranslationStrings = { 'admin.plugins.deactivated': 'Plugin disattivato', 'admin.plugins.viewErrors': 'Visualizza log errori', 'admin.plugins.errorLog': 'Log errori', + 'admin.plugins.allowedHosts': 'Host consentiti', + 'admin.plugins.allowedHosts.hint': 'Questo plugin comunica con un servizio che solo tu puoi indicare (un server self-hosted). Aggiungi gli host che può raggiungere — non ne raggiungerà altri.', + 'admin.plugins.allowedHosts.none': 'Nessun host aggiunto.', + 'admin.plugins.allowedHosts.unsupported': 'Questo plugin non usa host forniti dall’operatore. I suoi host consentiti sono fissati nel manifest.', + 'admin.plugins.allowedHosts.restartNote': 'Il salvataggio riavvia il plugin perché prenda la nuova lista.', + 'admin.plugins.allowedHosts.add': 'Aggiungi host consentito', + 'admin.plugins.allowedHosts.count': '{n} host consentiti', + 'admin.plugins.operatorEgressPill': '+ host aggiunti da te', + 'admin.plugins.operatorEgressHint': 'Questo plugin comunica con un servizio che solo tu puoi indicare (un server self-hosted). Dopo l’installazione, aggiungi gli host che può raggiungere in ⋯ → Host consentiti. Non ne raggiungerà altri.', 'admin.plugins.noErrors': 'Nessun errore registrato.', 'admin.plugins.uninstalled': 'Plugin disinstallato', 'admin.plugins.uninstallTitle': 'Disinstallare il plugin?', @@ -235,33 +245,53 @@ const admin: TranslationStrings = { 'admin.plugins.perm.db:read:users': 'Leggere le informazioni di base del profilo (nome, avatar — mai le credenziali)', 'admin.plugins.perm.db:read:costs': "Leggere le spese (voci di budget) a cui l'utente attivo ha accesso", 'admin.plugins.perm.db:read:packing': "Leggere le liste dei bagagli dei viaggi a cui l'utente attivo ha accesso", - 'admin.plugins.perm.db:write:packing': "Creare, modificare ed eliminare voci della lista bagagli sui viaggi che l'utente attivo può modificare", + 'admin.plugins.perm.db:write:packing': + "Creare, modificare ed eliminare voci della lista bagagli sui viaggi che l'utente attivo può modificare", 'admin.plugins.perm.weather:read': "Leggere le previsioni meteo in cache dell'host (per coordinate)", 'admin.plugins.perm.db:read:categories': "Leggere l'elenco globale delle categorie di luoghi", 'admin.plugins.perm.db:read:tags': "Leggere i tag dell'utente attivo", 'admin.plugins.perm.db:write:tags': "Creare, modificare ed eliminare i tag dell'utente attivo", 'admin.plugins.perm.db:read:todos': "Leggere le attività dei viaggi a cui l'utente attivo ha accesso", - 'admin.plugins.perm.db:write:todos': "Creare, modificare ed eliminare attività sui viaggi che l'utente attivo può modificare", - 'admin.plugins.perm.db:write:atlas': "Contrassegnare paesi e regioni come visitati e gestire la bucket list dell'utente attivo (richiede il componente aggiuntivo Atlas)", - 'admin.plugins.perm.db:write:vacay': "Attivare/disattivare giorni di ferie e chiusure aziendali sul piano attivo dell'utente attivo (richiede il componente aggiuntivo Vacay)", - 'admin.plugins.perm.db:write:journal': "Creare, modificare ed eliminare voci di diario nei diari che l'utente attivo può modificare (richiede il componente aggiuntivo Journey)", - 'admin.plugins.perm.db:write:collections': "Creare e modificare raccolte e salvarvi luoghi, con il ruolo di raccolta dell'utente attivo (richiede il componente aggiuntivo Collections)", - 'admin.plugins.perm.db:write:files': "Allegare file ai viaggi che l'utente attivo può modificare (limite 10 MB, estensioni bloccate rifiutate) e gestirne i collegamenti", - 'admin.plugins.perm.db:write:collab': "Pubblicare note, sondaggi e messaggi di chat sui viaggi che l'utente attivo può modificare (richiede il componente aggiuntivo Collab)", - 'admin.plugins.perm.db:write:members': "Aggiungere utenti ai viaggi — concede l'accesso al viaggio; protetto dal diritto di gestione membri dell'utente attivo", - 'admin.plugins.perm.notify:send': "Inviare una notifica (casella campanella + email/ntfy) all'utente attivo o a un viaggio a cui appartiene — mai a un destinatario arbitrario", - 'admin.plugins.perm.ai:invoke': "Usare il provider IA configurato dall'amministratore (completamento testo + estrazione documenti) per conto dell'utente attivo — il componente non detiene mai una chiave", - 'admin.plugins.perm.oauth:client': "Connettersi a un servizio di terze parti per tuo conto tramite OAuth gestito dall'host (l'host detiene i token, il plugin non li vede mai)", + 'admin.plugins.perm.db:write:todos': + "Creare, modificare ed eliminare attività sui viaggi che l'utente attivo può modificare", + 'admin.plugins.perm.db:write:atlas': + "Contrassegnare paesi e regioni come visitati e gestire la bucket list dell'utente attivo (richiede il componente aggiuntivo Atlas)", + 'admin.plugins.perm.db:write:vacay': + "Attivare/disattivare giorni di ferie e chiusure aziendali sul piano attivo dell'utente attivo (richiede il componente aggiuntivo Vacay)", + 'admin.plugins.perm.db:write:journal': + "Creare, modificare ed eliminare voci di diario nei diari che l'utente attivo può modificare (richiede il componente aggiuntivo Journey)", + 'admin.plugins.perm.db:write:collections': + "Creare e modificare raccolte e salvarvi luoghi, con il ruolo di raccolta dell'utente attivo (richiede il componente aggiuntivo Collections)", + 'admin.plugins.perm.db:write:files': + "Allegare file ai viaggi che l'utente attivo può modificare (limite 10 MB, estensioni bloccate rifiutate) e gestirne i collegamenti", + 'admin.plugins.perm.db:write:collab': + "Pubblicare note, sondaggi e messaggi di chat sui viaggi che l'utente attivo può modificare (richiede il componente aggiuntivo Collab)", + 'admin.plugins.perm.db:write:members': + "Aggiungere utenti ai viaggi — concede l'accesso al viaggio; protetto dal diritto di gestione membri dell'utente attivo", + 'admin.plugins.perm.notify:send': + "Inviare una notifica (casella campanella + email/ntfy) all'utente attivo o a un viaggio a cui appartiene — mai a un destinatario arbitrario", + 'admin.plugins.perm.ai:invoke': + "Usare il provider IA configurato dall'amministratore (completamento testo + estrazione documenti) per conto dell'utente attivo — il componente non detiene mai una chiave", + 'admin.plugins.perm.oauth:client': + "Connettersi a un servizio di terze parti per tuo conto tramite OAuth gestito dall'host (l'host detiene i token, il plugin non li vede mai)", 'admin.plugins.perm.db:read:files': "Leggere i file dei viaggi a cui l'utente attivo ha accesso", - 'admin.plugins.perm.db:write:reservations': "Creare, modificare ed eliminare prenotazioni sui viaggi che l'utente attivo può modificare", - 'admin.plugins.perm.db:write:accommodations': "Creare, modificare ed eliminare alloggi sui viaggi che l'utente attivo può modificare", - 'admin.plugins.perm.db:read:journal': "Leggere i diari di viaggio dell'utente attivo (richiede il componente aggiuntivo Journey)", - 'admin.plugins.perm.db:read:atlas': "Leggere i paesi e le regioni visitati dall'utente attivo (richiede il componente aggiuntivo Atlas)", - 'admin.plugins.perm.db:read:vacay': "Leggere il piano vacanze dell'utente attivo (richiede il componente aggiuntivo Vacay)", + 'admin.plugins.perm.db:write:reservations': + "Creare, modificare ed eliminare prenotazioni sui viaggi che l'utente attivo può modificare", + 'admin.plugins.perm.db:write:accommodations': + "Creare, modificare ed eliminare alloggi sui viaggi che l'utente attivo può modificare", + 'admin.plugins.perm.db:read:journal': + "Leggere i diari di viaggio dell'utente attivo (richiede il componente aggiuntivo Journey)", + 'admin.plugins.perm.db:read:atlas': + "Leggere i paesi e le regioni visitati dall'utente attivo (richiede il componente aggiuntivo Atlas)", + 'admin.plugins.perm.db:read:vacay': + "Leggere il piano vacanze dell'utente attivo (richiede il componente aggiuntivo Vacay)", 'admin.plugins.perm.db:read:daynotes': "Leggere le note del giorno dei viaggi a cui l'utente attivo ha accesso", - 'admin.plugins.perm.db:read:collections': "Leggere le raccolte di luoghi salvati dell'utente attivo (richiede il componente aggiuntivo Collections)", - 'admin.plugins.perm.db:write:daynotes': "Creare, modificare ed eliminare note del giorno sui viaggi che l'utente attivo può modificare", - 'admin.plugins.perm.jobs:run': 'Eseguire i job in background dichiarati secondo una pianificazione (senza contesto utente — non può leggere i dati utente)', + 'admin.plugins.perm.db:read:collections': + "Leggere le raccolte di luoghi salvati dell'utente attivo (richiede il componente aggiuntivo Collections)", + 'admin.plugins.perm.db:write:daynotes': + "Creare, modificare ed eliminare note del giorno sui viaggi che l'utente attivo può modificare", + 'admin.plugins.perm.jobs:run': + 'Eseguire i job in background dichiarati secondo una pianificazione (senza contesto utente — non può leggere i dati utente)', 'admin.plugins.perm.db:write:costs': "Creare spese (voci di budget) sui viaggi che l'utente attivo può modificare", 'admin.plugins.perm.db:write:places': "Aggiungere, modificare e rimuovere luoghi sui viaggi che l'utente attivo può modificare", @@ -281,19 +311,26 @@ const admin: TranslationStrings = { 'Fornire dettagli aggiuntivi (recensioni, valutazioni, link) a un luogo', 'admin.plugins.perm.hook:trip-warning-provider': 'Segnala avvisi di validazione su un viaggio (mostrati nel pianificatore)', - 'admin.plugins.perm.hook:table-contributor': 'Aggiungere colonne e azioni alle viste del viaggio (prenotazioni, luoghi, giorni)', - 'admin.plugins.perm.hook:map-marker-provider': 'Aggiungere indicatori alla mappa del viaggio (es. mostrare prenotazioni o POI)', + 'admin.plugins.perm.hook:table-contributor': + 'Aggiungere colonne e azioni alle viste del viaggio (prenotazioni, luoghi, giorni)', + 'admin.plugins.perm.hook:map-marker-provider': + 'Aggiungere indicatori alla mappa del viaggio (es. mostrare prenotazioni o POI)', 'admin.plugins.perm.hook:pdf-section-provider': "Aggiungere sezioni di testo all'esportazione PDF del viaggio", 'admin.plugins.perm.hook:atlas-layer-provider': 'Evidenziare paesi sulla mappa del mondo di Atlas (es. liste dei desideri o avvisi di viaggio)', 'admin.plugins.perm.hook:journal-entry-provider': 'Aggiungere righe extra (link, statistiche) alle voci del diario', - 'admin.plugins.perm.hook:user-data': 'Cancellare o esportare i dati che memorizza su un utente (GDPR: eliminazione account e richieste dati)', - 'admin.plugins.perm.hook:trip-card-provider': 'Aggiungere piccoli badge (stato, conteggi) alle schede viaggio della dashboard', + 'admin.plugins.perm.hook:user-data': + 'Cancellare o esportare i dati che memorizza su un utente (GDPR: eliminazione account e richieste dati)', + 'admin.plugins.perm.hook:trip-card-provider': + 'Aggiungere piccoli badge (stato, conteggi) alle schede viaggio della dashboard', + 'admin.plugins.perm.hook:notification-channel': 'Inviare le tue notifiche tramite un canale aggiuntivo', 'admin.plugins.perm.events:subscribe': "Reagisce agli eventi principali dell'attività (solo nome evento e viaggio, mai il contenuto)", 'admin.plugins.perm.http:outbound': 'Effettuare richieste in uscita verso gli host dichiarati', - 'admin.plugins.perm.db:read:collab': "Leggere note, sondaggi e messaggi di chat dei viaggi a cui l'utente attivo ha accesso (richiede il componente aggiuntivo Collab)", - 'admin.plugins.perm.db:read:files:content': "Leggere il contenuto (byte) dei file dei viaggi a cui l'utente attivo ha accesso", + 'admin.plugins.perm.db:read:collab': + "Leggere note, sondaggi e messaggi di chat dei viaggi a cui l'utente attivo ha accesso (richiede il componente aggiuntivo Collab)", + 'admin.plugins.perm.db:read:files:content': + "Leggere il contenuto (byte) dei file dei viaggi a cui l'utente attivo ha accesso", 'admin.plugins.perm.db:create:trips': "Creare nuovi viaggi di proprietà dell'utente attivo", 'admin.plugins.perm.rates:read': "Leggere i tassi di cambio in cache dell'host", 'admin.plugins.updateConsentTitle': 'Questo aggiornamento richiede nuovi permessi', @@ -363,6 +400,7 @@ const admin: TranslationStrings = { 'admin.plugins.cap.reservationSlot': 'Dettaglio prenotazione', 'admin.plugins.cap.replacesTabs': 'Sostituisce schede del planner', 'admin.plugins.cap.realtime': 'Aggiornamenti in tempo reale', + 'admin.plugins.cap.notificationChannel': 'Canale di notifica', 'admin.plugins.cap.photos': 'Fornisce foto', 'admin.plugins.cap.calendar': 'Fornisce eventi calendario', 'admin.plugins.cap.placeDetails': 'Arricchisce luoghi', diff --git a/shared/src/i18n/it/settings.ts b/shared/src/i18n/it/settings.ts index 94341e6bb..3ebb7c2a4 100644 --- a/shared/src/i18n/it/settings.ts +++ b/shared/src/i18n/it/settings.ts @@ -289,6 +289,14 @@ const settings: TranslationStrings = { 'settings.ntfyUrl.testFailed': 'Notifica di test Ntfy fallita', 'settings.ntfyUrl.tokenCleared': 'Token di accesso rimosso', 'settings.notificationPreferences.inapp': 'In-App', + 'settings.notificationPreferences.notConfigured': 'Non ancora configurato — impostalo nelle impostazioni del plugin', + 'settings.plugins.actions': 'Azioni', + 'settings.plugins.actions.confirm': 'Eseguire questa azione?', + 'settings.notificationPreferences.sendTest': 'Invia test', + 'settings.notificationPreferences.configure': 'Configura', + 'settings.notificationPreferences.testSuccess': 'Notifica di prova inviata.', + 'settings.notificationPreferences.testFailed': 'Test non riuscito.', + 'settings.notificationPreferences.pluginConfigured': 'Configurato. Gestisci le credenziali nelle impostazioni del plugin.', 'settings.notificationPreferences.webhook': 'Webhook', 'settings.notificationPreferences.email': 'Email', 'settings.notificationPreferences.ntfy': 'Ntfy', diff --git a/shared/src/i18n/ja/admin.ts b/shared/src/i18n/ja/admin.ts index a8319a4f2..723cb43ac 100644 --- a/shared/src/i18n/ja/admin.ts +++ b/shared/src/i18n/ja/admin.ts @@ -200,7 +200,8 @@ const admin: TranslationStrings = { 'admin.plugins.uploaded': 'プラグイン「{name}」をアップロードしました — 実行するには有効化してください', 'admin.plugins.sideloaded': '手動読み込み', 'admin.plugins.devLinkBadge': 'Dev-Link', - 'admin.plugins.devLinkHint': 'ローカルのビルドディレクトリから読み込まれ、実データでホットリロードされます(開発専用)', + 'admin.plugins.devLinkHint': + 'ローカルのビルドディレクトリから読み込まれ、実データでホットリロードされます(開発専用)', 'admin.plugins.devLinkTitle': 'ローカルプラグインをリンク', 'admin.plugins.devLinkPathPlaceholder': '/absolute/path/to/your/plugin', 'admin.plugins.devLinkButton': 'リンク', @@ -217,6 +218,15 @@ const admin: TranslationStrings = { 'admin.plugins.deactivated': 'プラグインを無効化しました', 'admin.plugins.viewErrors': 'エラーログを表示', 'admin.plugins.errorLog': 'エラーログ', + 'admin.plugins.allowedHosts': '許可するホスト', + 'admin.plugins.allowedHosts.hint': 'このプラグインは、あなたしか指定できないサービス(セルフホストのサーバー)と通信します。到達を許可するホストを追加してください。それ以外には到達できません。', + 'admin.plugins.allowedHosts.none': 'ホストはまだ追加されていません。', + 'admin.plugins.allowedHosts.unsupported': 'このプラグインは運用者指定のホストを使いません。許可ホストはマニフェストで固定されています。', + 'admin.plugins.allowedHosts.restartNote': '保存するとプラグインが再起動し、新しい一覧が反映されます。', + 'admin.plugins.allowedHosts.add': '許可ホストを追加', + 'admin.plugins.allowedHosts.count': '許可ホスト {n} 件', + 'admin.plugins.operatorEgressPill': '+ 追加するホスト', + 'admin.plugins.operatorEgressHint': 'このプラグインは、あなたしか指定できないサービス(セルフホストのサーバー)と通信します。インストール後、⋯ → 許可するホスト から追加してください。それ以外には到達できません。', 'admin.plugins.noErrors': '記録されたエラーはありません。', 'admin.plugins.uninstalled': 'プラグインをアンインストールしました', 'admin.plugins.uninstallTitle': 'プラグインをアンインストールしますか?', @@ -274,16 +284,26 @@ const admin: TranslationStrings = { 'admin.plugins.perm.db:write:tags': '操作中のユーザーのタグの作成・編集・削除', 'admin.plugins.perm.db:read:todos': '操作中のユーザーがアクセスできる旅行のToDoを読み取る', 'admin.plugins.perm.db:write:todos': '操作中のユーザーが編集できる旅行のToDoの作成・編集・削除', - 'admin.plugins.perm.db:write:atlas': '操作中のユーザーの訪問済みの国と地域をマークし、バケットリストを管理(Atlas アドオンが必要)', - 'admin.plugins.perm.db:write:vacay': '操作中のユーザーのアクティブなプランで休暇日と会社休日を切り替え(Vacay アドオンが必要)', - 'admin.plugins.perm.db:write:journal': '操作中のユーザーが編集できる日記のエントリの作成・編集・削除(Journey アドオンが必要)', - 'admin.plugins.perm.db:write:collections': 'コレクションの作成・編集と場所の保存(操作中のユーザーのコレクションロールで実行、Collections アドオンが必要)', - 'admin.plugins.perm.db:write:files': '操作中のユーザーが編集できる旅行にファイルを添付(10MB上限、ブロック対象の拡張子は拒否)し、そのリンクを管理', - 'admin.plugins.perm.db:write:collab': '操作中のユーザーが編集できる旅行にメモ・アンケート・チャットメッセージを投稿(Collab アドオンが必要)', - 'admin.plugins.perm.db:write:members': '旅行にユーザーを追加 — 旅行へのアクセスを付与。操作中のユーザーのメンバー管理権限で保護', - 'admin.plugins.perm.notify:send': '操作中のユーザー、またはそのユーザーが所属する旅行に通知(ベル受信箱+メール/ntfy)を送信 — 任意の受信者には送らない', - 'admin.plugins.perm.ai:invoke': '管理者が構成した AI プロバイダー(テキスト補完+ドキュメント抽出)を操作中のユーザーの代わりに実行 — プラグインはキーを保持しない', - 'admin.plugins.perm.oauth:client': 'ホスト仲介の OAuth であなたに代わってサードパーティサービスに接続(トークンはホストが保持し、プラグインは決して見ない)', + 'admin.plugins.perm.db:write:atlas': + '操作中のユーザーの訪問済みの国と地域をマークし、バケットリストを管理(Atlas アドオンが必要)', + 'admin.plugins.perm.db:write:vacay': + '操作中のユーザーのアクティブなプランで休暇日と会社休日を切り替え(Vacay アドオンが必要)', + 'admin.plugins.perm.db:write:journal': + '操作中のユーザーが編集できる日記のエントリの作成・編集・削除(Journey アドオンが必要)', + 'admin.plugins.perm.db:write:collections': + 'コレクションの作成・編集と場所の保存(操作中のユーザーのコレクションロールで実行、Collections アドオンが必要)', + 'admin.plugins.perm.db:write:files': + '操作中のユーザーが編集できる旅行にファイルを添付(10MB上限、ブロック対象の拡張子は拒否)し、そのリンクを管理', + 'admin.plugins.perm.db:write:collab': + '操作中のユーザーが編集できる旅行にメモ・アンケート・チャットメッセージを投稿(Collab アドオンが必要)', + 'admin.plugins.perm.db:write:members': + '旅行にユーザーを追加 — 旅行へのアクセスを付与。操作中のユーザーのメンバー管理権限で保護', + 'admin.plugins.perm.notify:send': + '操作中のユーザー、またはそのユーザーが所属する旅行に通知(ベル受信箱+メール/ntfy)を送信 — 任意の受信者には送らない', + 'admin.plugins.perm.ai:invoke': + '管理者が構成した AI プロバイダー(テキスト補完+ドキュメント抽出)を操作中のユーザーの代わりに実行 — プラグインはキーを保持しない', + 'admin.plugins.perm.oauth:client': + 'ホスト仲介の OAuth であなたに代わってサードパーティサービスに接続(トークンはホストが保持し、プラグインは決して見ない)', 'admin.plugins.perm.db:read:files': '操作中のユーザーがアクセスできる旅行のファイルの読み取り', 'admin.plugins.perm.db:write:reservations': '操作中のユーザーが編集できる旅行の予約の作成・編集・削除', 'admin.plugins.perm.db:write:accommodations': '操作中のユーザーが編集できる旅行の宿泊の作成・編集・削除', @@ -291,9 +311,11 @@ const admin: TranslationStrings = { 'admin.plugins.perm.db:read:atlas': '操作中のユーザーが訪問した国と地域の読み取り(Atlas アドオンが必要)', 'admin.plugins.perm.db:read:vacay': '操作中のユーザーの休暇プランの読み取り(Vacay アドオンが必要)', 'admin.plugins.perm.db:read:daynotes': '操作中のユーザーがアクセスできる旅行の日別メモの読み取り', - 'admin.plugins.perm.db:read:collections': '操作中のユーザーの保存した場所のコレクションの読み取り(Collections アドオンが必要)', + 'admin.plugins.perm.db:read:collections': + '操作中のユーザーの保存した場所のコレクションの読み取り(Collections アドオンが必要)', 'admin.plugins.perm.db:write:daynotes': '操作中のユーザーが編集できる旅行の日別メモの作成・編集・削除', - 'admin.plugins.perm.jobs:run': '宣言されたバックグラウンドジョブをスケジュールに従って実行(ユーザーコンテキストなし — ユーザーデータは読み取れません)', + 'admin.plugins.perm.jobs:run': + '宣言されたバックグラウンドジョブをスケジュールに従って実行(ユーザーコンテキストなし — ユーザーデータは読み取れません)', 'admin.plugins.perm.db:write:costs': '操作中のユーザーが編集できる旅行への支出(予算項目)の作成', 'admin.plugins.perm.db:write:places': '操作中のユーザーが編集できる旅行の場所の追加・編集・削除', 'admin.plugins.perm.db:write:days': '操作中のユーザーが編集できる旅行の日程の追加・編集・削除', @@ -312,12 +334,15 @@ const admin: TranslationStrings = { 'admin.plugins.perm.hook:pdf-section-provider': '旅行の PDF エクスポートにテキストセクションを追加', 'admin.plugins.perm.hook:atlas-layer-provider': 'Atlas の世界地図で国をハイライト(例: ウィッシュリストや渡航情報)', 'admin.plugins.perm.hook:journal-entry-provider': '日記のエントリーに追加の行(リンク、統計)を提供', - 'admin.plugins.perm.hook:user-data': 'ユーザーについて保存したデータの消去またはエクスポート(GDPR:アカウント削除・データ開示請求)', + 'admin.plugins.perm.hook:user-data': + 'ユーザーについて保存したデータの消去またはエクスポート(GDPR:アカウント削除・データ開示請求)', 'admin.plugins.perm.hook:trip-card-provider': 'ダッシュボードの旅行カードに小さなバッジ(状態・カウント)を追加', + 'admin.plugins.perm.hook:notification-channel': '追加のチャンネルで通知を配信する', 'admin.plugins.perm.events:subscribe': 'コアのアクティビティイベントに反応(イベント名と旅程のみで、内容には一切アクセスしません)', 'admin.plugins.perm.http:outbound': '宣言済みホストへの外部リクエストの送信', - 'admin.plugins.perm.db:read:collab': '操作中のユーザーがアクセスできる旅行のメモ・アンケート・チャットメッセージの読み取り(Collab アドオンが必要)', + 'admin.plugins.perm.db:read:collab': + '操作中のユーザーがアクセスできる旅行のメモ・アンケート・チャットメッセージの読み取り(Collab アドオンが必要)', 'admin.plugins.perm.db:read:files:content': '操作中のユーザーがアクセスできる旅行のファイル内容(バイト)の読み取り', 'admin.plugins.perm.db:create:trips': '操作中のユーザーが所有する新しい旅行の作成', 'admin.plugins.perm.rates:read': 'ホストのキャッシュされた為替レートを読み取る', @@ -388,6 +413,7 @@ const admin: TranslationStrings = { 'admin.plugins.cap.reservationSlot': '予約詳細', 'admin.plugins.cap.replacesTabs': 'プランナーのタブを置き換え', 'admin.plugins.cap.realtime': 'リアルタイム更新', + 'admin.plugins.cap.notificationChannel': '通知チャンネル', 'admin.plugins.cap.photos': '写真を提供', 'admin.plugins.cap.calendar': 'カレンダー予定を提供', 'admin.plugins.cap.placeDetails': '場所を充実', diff --git a/shared/src/i18n/ja/settings.ts b/shared/src/i18n/ja/settings.ts index 2f0a1f697..ddf4f7a86 100644 --- a/shared/src/i18n/ja/settings.ts +++ b/shared/src/i18n/ja/settings.ts @@ -89,6 +89,14 @@ const settings: TranslationStrings = { 'settings.notificationPreferences.email': 'メール', 'settings.notificationPreferences.webhook': 'Webhook', 'settings.notificationPreferences.inapp': 'アプリ内', + 'settings.notificationPreferences.notConfigured': 'まだ設定されていません — プラグインの設定で構成してください', + 'settings.plugins.actions': 'アクション', + 'settings.plugins.actions.confirm': 'このアクションを実行しますか?', + 'settings.notificationPreferences.sendTest': 'テスト送信', + 'settings.notificationPreferences.configure': '設定する', + 'settings.notificationPreferences.testSuccess': 'テスト通知を送信しました。', + 'settings.notificationPreferences.testFailed': 'テストに失敗しました。', + 'settings.notificationPreferences.pluginConfigured': '設定済みです。認証情報はプラグインの設定ページで管理します。', 'settings.notificationPreferences.ntfy': 'Ntfy', 'settings.notificationPreferences.noChannels': '通知チャネルが未設定です。管理者に設定を依頼してください。', 'settings.webhookUrl.label': 'Webhook URL', diff --git a/shared/src/i18n/ko/admin.ts b/shared/src/i18n/ko/admin.ts index d6fada553..a9f2ca617 100644 --- a/shared/src/i18n/ko/admin.ts +++ b/shared/src/i18n/ko/admin.ts @@ -223,6 +223,15 @@ const admin: TranslationStrings = { 'admin.plugins.deactivated': '플러그인이 비활성화되었습니다', 'admin.plugins.viewErrors': '오류 로그 보기', 'admin.plugins.errorLog': '오류 로그', + 'admin.plugins.allowedHosts': '허용된 호스트', + 'admin.plugins.allowedHosts.hint': '이 플러그인은 관리자만 알 수 있는 서비스(자체 호스팅 서버)와 통신합니다. 접근을 허용할 호스트를 추가하세요 — 그 외에는 접근할 수 없습니다.', + 'admin.plugins.allowedHosts.none': '아직 추가된 호스트가 없습니다.', + 'admin.plugins.allowedHosts.unsupported': '이 플러그인은 운영자가 지정하는 호스트를 사용하지 않습니다. 허용 호스트는 매니페스트에 고정되어 있습니다.', + 'admin.plugins.allowedHosts.restartNote': '저장하면 플러그인이 재시작되어 새 목록을 적용합니다.', + 'admin.plugins.allowedHosts.add': '허용 호스트 추가', + 'admin.plugins.allowedHosts.count': '허용 호스트 {n}개', + 'admin.plugins.operatorEgressPill': '+ 직접 추가하는 호스트', + 'admin.plugins.operatorEgressHint': '이 플러그인은 관리자만 알 수 있는 서비스(자체 호스팅 서버)와 통신합니다. 설치 후 ⋯ → 허용된 호스트에서 추가하세요. 그 외에는 접근할 수 없습니다.', 'admin.plugins.noErrors': '기록된 오류가 없습니다.', 'admin.plugins.uninstalled': '플러그인이 제거되었습니다', 'admin.plugins.uninstallTitle': '플러그인을 제거할까요?', @@ -280,16 +289,25 @@ const admin: TranslationStrings = { 'admin.plugins.perm.db:write:tags': '현재 사용자의 태그 만들기, 편집 및 삭제', 'admin.plugins.perm.db:read:todos': '현재 사용자가 접근할 수 있는 여행의 할 일 읽기', 'admin.plugins.perm.db:write:todos': '현재 사용자가 편집할 수 있는 여행에서 할 일 만들기, 편집 및 삭제', - 'admin.plugins.perm.db:write:atlas': '현재 사용자의 방문한 국가와 지역을 표시하고 버킷 리스트 관리(Atlas 애드온 필요)', + 'admin.plugins.perm.db:write:atlas': + '현재 사용자의 방문한 국가와 지역을 표시하고 버킷 리스트 관리(Atlas 애드온 필요)', 'admin.plugins.perm.db:write:vacay': '현재 사용자의 활성 계획에서 휴가일과 회사 휴일 전환(Vacay 애드온 필요)', - 'admin.plugins.perm.db:write:journal': '현재 사용자가 편집할 수 있는 일지에서 항목 만들기, 편집 및 삭제(Journey 애드온 필요)', - 'admin.plugins.perm.db:write:collections': '컬렉션을 만들고 편집하며 장소 저장(현재 사용자의 컬렉션 역할 기준, Collections 애드온 필요)', - 'admin.plugins.perm.db:write:files': '현재 사용자가 편집할 수 있는 여행에 파일 첨부(10MB 제한, 차단된 확장자 거부) 및 링크 관리', - 'admin.plugins.perm.db:write:collab': '현재 사용자가 편집할 수 있는 여행에 메모, 투표 및 채팅 메시지 게시(Collab 애드온 필요)', - 'admin.plugins.perm.db:write:members': '여행에 사용자 추가 — 여행 접근 권한 부여; 현재 사용자의 멤버 관리 권한으로 보호', - 'admin.plugins.perm.notify:send': '현재 사용자 또는 그가 속한 여행에 알림(벨 받은함 + 이메일/ntfy) 전송 — 임의의 수신자에게는 보내지 않음', - 'admin.plugins.perm.ai:invoke': '관리자가 구성한 AI 공급자(텍스트 완성 + 문서 추출)를 현재 사용자를 대신하여 실행 — 플러그인은 키를 보유하지 않음', - 'admin.plugins.perm.oauth:client': '호스트가 중개하는 OAuth로 사용자를 대신해 타사 서비스에 연결(토큰은 호스트가 보유하며 플러그인은 볼 수 없음)', + 'admin.plugins.perm.db:write:journal': + '현재 사용자가 편집할 수 있는 일지에서 항목 만들기, 편집 및 삭제(Journey 애드온 필요)', + 'admin.plugins.perm.db:write:collections': + '컬렉션을 만들고 편집하며 장소 저장(현재 사용자의 컬렉션 역할 기준, Collections 애드온 필요)', + 'admin.plugins.perm.db:write:files': + '현재 사용자가 편집할 수 있는 여행에 파일 첨부(10MB 제한, 차단된 확장자 거부) 및 링크 관리', + 'admin.plugins.perm.db:write:collab': + '현재 사용자가 편집할 수 있는 여행에 메모, 투표 및 채팅 메시지 게시(Collab 애드온 필요)', + 'admin.plugins.perm.db:write:members': + '여행에 사용자 추가 — 여행 접근 권한 부여; 현재 사용자의 멤버 관리 권한으로 보호', + 'admin.plugins.perm.notify:send': + '현재 사용자 또는 그가 속한 여행에 알림(벨 받은함 + 이메일/ntfy) 전송 — 임의의 수신자에게는 보내지 않음', + 'admin.plugins.perm.ai:invoke': + '관리자가 구성한 AI 공급자(텍스트 완성 + 문서 추출)를 현재 사용자를 대신하여 실행 — 플러그인은 키를 보유하지 않음', + 'admin.plugins.perm.oauth:client': + '호스트가 중개하는 OAuth로 사용자를 대신해 타사 서비스에 연결(토큰은 호스트가 보유하며 플러그인은 볼 수 없음)', 'admin.plugins.perm.db:read:files': '현재 사용자가 접근할 수 있는 여행의 파일 읽기', 'admin.plugins.perm.db:write:reservations': '현재 사용자가 편집할 수 있는 여행에서 예약 만들기, 편집 및 삭제', 'admin.plugins.perm.db:write:accommodations': '현재 사용자가 편집할 수 있는 여행에서 숙소 만들기, 편집 및 삭제', @@ -299,7 +317,8 @@ const admin: TranslationStrings = { 'admin.plugins.perm.db:read:daynotes': '현재 사용자가 접근할 수 있는 여행의 일자 메모 읽기', 'admin.plugins.perm.db:read:collections': '현재 사용자의 저장한 장소 컬렉션 읽기(Collections 애드온 필요)', 'admin.plugins.perm.db:write:daynotes': '현재 사용자가 편집할 수 있는 여행에서 일자 메모 만들기, 편집 및 삭제', - 'admin.plugins.perm.jobs:run': '선언된 백그라운드 작업을 예약에 따라 실행(사용자 컨텍스트 없음 — 사용자 데이터를 읽을 수 없음)', + 'admin.plugins.perm.jobs:run': + '선언된 백그라운드 작업을 예약에 따라 실행(사용자 컨텍스트 없음 — 사용자 데이터를 읽을 수 없음)', 'admin.plugins.perm.db:write:costs': '현재 사용자가 편집할 수 있는 여행에 지출(예산 항목) 만들기', 'admin.plugins.perm.db:write:places': '현재 사용자가 편집할 수 있는 여행에서 장소 추가, 편집 및 삭제', 'admin.plugins.perm.db:write:days': '현재 사용자가 편집할 수 있는 여행에서 날짜 추가, 편집 및 삭제', @@ -317,11 +336,14 @@ const admin: TranslationStrings = { 'admin.plugins.perm.hook:pdf-section-provider': '여행 PDF 내보내기에 텍스트 섹션 추가', 'admin.plugins.perm.hook:atlas-layer-provider': 'Atlas 세계 지도에서 국가 강조 표시(예: 위시리스트 또는 여행 경보)', 'admin.plugins.perm.hook:journal-entry-provider': '저널 항목에 추가 행(링크, 통계) 제공', - 'admin.plugins.perm.hook:user-data': '사용자에 대해 저장한 데이터를 삭제하거나 내보내기 (GDPR: 계정 삭제 및 데이터 요청)', + 'admin.plugins.perm.hook:user-data': + '사용자에 대해 저장한 데이터를 삭제하거나 내보내기 (GDPR: 계정 삭제 및 데이터 요청)', 'admin.plugins.perm.hook:trip-card-provider': '대시보드 여행 카드에 작은 배지(상태, 개수) 추가', + 'admin.plugins.perm.hook:notification-channel': '추가 채널을 통해 알림을 전달합니다', 'admin.plugins.perm.events:subscribe': '핵심 활동 이벤트에 반응 (이벤트 이름과 여행만, 내용은 절대 표시 안 함)', 'admin.plugins.perm.http:outbound': '선언된 호스트로 아웃바운드 요청 전송', - 'admin.plugins.perm.db:read:collab': '현재 사용자가 접근할 수 있는 여행의 메모, 투표 및 채팅 메시지 읽기(Collab 애드온 필요)', + 'admin.plugins.perm.db:read:collab': + '현재 사용자가 접근할 수 있는 여행의 메모, 투표 및 채팅 메시지 읽기(Collab 애드온 필요)', 'admin.plugins.perm.db:read:files:content': '현재 사용자가 접근할 수 있는 여행의 파일 내용(바이트) 읽기', 'admin.plugins.perm.db:create:trips': '현재 사용자 소유의 새 여행 만들기', 'admin.plugins.perm.rates:read': '호스트의 캐시된 환율 읽기', @@ -392,6 +414,7 @@ const admin: TranslationStrings = { 'admin.plugins.cap.reservationSlot': '예약 상세', 'admin.plugins.cap.replacesTabs': '플래너 탭 대체', 'admin.plugins.cap.realtime': '실시간 업데이트', + 'admin.plugins.cap.notificationChannel': '알림 채널', 'admin.plugins.cap.photos': '사진 제공', 'admin.plugins.cap.calendar': '캘린더 일정 제공', 'admin.plugins.cap.placeDetails': '장소 정보 강화', diff --git a/shared/src/i18n/ko/settings.ts b/shared/src/i18n/ko/settings.ts index 04424350d..23444cd21 100644 --- a/shared/src/i18n/ko/settings.ts +++ b/shared/src/i18n/ko/settings.ts @@ -89,6 +89,14 @@ const settings: TranslationStrings = { 'settings.notificationPreferences.email': '이메일', 'settings.notificationPreferences.webhook': '웹훅', 'settings.notificationPreferences.inapp': '앱 내', + 'settings.notificationPreferences.notConfigured': '아직 설정되지 않았습니다 — 플러그인 설정에서 구성하세요', + 'settings.plugins.actions': '작업', + 'settings.plugins.actions.confirm': '이 작업을 실행할까요?', + 'settings.notificationPreferences.sendTest': '테스트 전송', + 'settings.notificationPreferences.configure': '설정하기', + 'settings.notificationPreferences.testSuccess': '테스트 알림을 보냈습니다.', + 'settings.notificationPreferences.testFailed': '테스트에 실패했습니다.', + 'settings.notificationPreferences.pluginConfigured': '설정됨. 자격 증명은 플러그인 설정 페이지에서 관리하세요.', 'settings.notificationPreferences.ntfy': 'Ntfy', 'settings.notificationPreferences.noChannels': '알림 채널이 설정되지 않았습니다. 관리자에게 이메일 또는 웹훅 알림 설정을 요청하세요.', diff --git a/shared/src/i18n/nl/admin.ts b/shared/src/i18n/nl/admin.ts index 7bc498284..fe0076ec2 100644 --- a/shared/src/i18n/nl/admin.ts +++ b/shared/src/i18n/nl/admin.ts @@ -169,7 +169,8 @@ const admin: TranslationStrings = { 'admin.plugins.uploaded': 'Plugin “{name}” geüpload — activeer om uit te voeren', 'admin.plugins.sideloaded': 'Handmatig geladen', 'admin.plugins.devLinkBadge': 'Dev-Link', - 'admin.plugins.devLinkHint': 'Geladen vanuit een lokale build-map en hot-reload met echte data — alleen voor ontwikkeling', + 'admin.plugins.devLinkHint': + 'Geladen vanuit een lokale build-map en hot-reload met echte data — alleen voor ontwikkeling', 'admin.plugins.devLinkTitle': 'Een lokale plugin koppelen', 'admin.plugins.devLinkPathPlaceholder': '/absolute/path/to/your/plugin', 'admin.plugins.devLinkButton': 'Koppelen', @@ -186,6 +187,15 @@ const admin: TranslationStrings = { 'admin.plugins.deactivated': 'Plugin gedeactiveerd', 'admin.plugins.viewErrors': 'Foutenlog bekijken', 'admin.plugins.errorLog': 'Foutenlog', + 'admin.plugins.allowedHosts': 'Toegestane hosts', + 'admin.plugins.allowedHosts.hint': 'Deze plug-in praat met een dienst die alleen jij kunt benoemen (een zelf-gehoste server). Voeg de hosts toe die hij mag bereiken — andere bereikt hij niet.', + 'admin.plugins.allowedHosts.none': 'Nog geen hosts toegevoegd.', + 'admin.plugins.allowedHosts.unsupported': 'Deze plug-in gebruikt geen door de beheerder opgegeven hosts. De toegestane hosts staan vast in het manifest.', + 'admin.plugins.allowedHosts.restartNote': 'Opslaan herstart de plug-in zodat de nieuwe lijst wordt opgepakt.', + 'admin.plugins.allowedHosts.add': 'Toegestane host toevoegen', + 'admin.plugins.allowedHosts.count': '{n} toegestane host(s)', + 'admin.plugins.operatorEgressPill': '+ hosts die jij toevoegt', + 'admin.plugins.operatorEgressHint': 'Deze plug-in praat met een dienst die alleen jij kunt benoemen (een zelf-gehoste server). Voeg na installatie de bereikbare hosts toe via ⋯ → Toegestane hosts. Andere bereikt hij niet.', 'admin.plugins.noErrors': 'Geen fouten geregistreerd.', 'admin.plugins.uninstalled': 'Plugin verwijderd', 'admin.plugins.uninstallTitle': 'Plugin verwijderen?', @@ -236,33 +246,51 @@ const admin: TranslationStrings = { 'admin.plugins.perm.db:read:users': 'Basisprofielgegevens lezen (naam, avatar — nooit inloggegevens)', 'admin.plugins.perm.db:read:costs': 'Uitgaven (budgetposten) lezen waartoe de actieve gebruiker toegang heeft', 'admin.plugins.perm.db:read:packing': 'Paklijsten lezen van reizen waartoe de actieve gebruiker toegang heeft', - 'admin.plugins.perm.db:write:packing': 'Paklijst-items aanmaken, bewerken en verwijderen op reizen die de actieve gebruiker mag bewerken', + 'admin.plugins.perm.db:write:packing': + 'Paklijst-items aanmaken, bewerken en verwijderen op reizen die de actieve gebruiker mag bewerken', 'admin.plugins.perm.weather:read': 'De gecachte weersvoorspelling van de host lezen (op coördinaten)', 'admin.plugins.perm.db:read:categories': 'De globale lijst met plaatscategorieën lezen', 'admin.plugins.perm.db:read:tags': 'De tags van de actieve gebruiker lezen', 'admin.plugins.perm.db:write:tags': 'De tags van de actieve gebruiker aanmaken, bewerken en verwijderen', 'admin.plugins.perm.db:read:todos': 'De taken lezen van reizen waartoe de actieve gebruiker toegang heeft', - 'admin.plugins.perm.db:write:todos': 'Taken aanmaken, bewerken en verwijderen op reizen die de actieve gebruiker mag bewerken', - 'admin.plugins.perm.db:write:atlas': "Landen en regio's als bezocht markeren en de bucketlist van de actieve gebruiker beheren (vereist de Atlas-add-on)", - 'admin.plugins.perm.db:write:vacay': 'Vakantiedagen en bedrijfssluitingen op het actieve plan van de actieve gebruiker omschakelen (vereist de Vacay-add-on)', - 'admin.plugins.perm.db:write:journal': 'Dagboekvermeldingen aanmaken, bewerken en verwijderen in dagboeken die de actieve gebruiker mag bewerken (vereist de Journey-add-on)', - 'admin.plugins.perm.db:write:collections': 'Collecties aanmaken en bewerken en er plaatsen in opslaan, met de collectierol van de actieve gebruiker (vereist de Collections-add-on)', - 'admin.plugins.perm.db:write:files': 'Bestanden toevoegen aan reizen die de actieve gebruiker mag bewerken (limiet 10 MB, geblokkeerde extensies geweigerd) en hun koppelingen beheren', - 'admin.plugins.perm.db:write:collab': 'Notities, peilingen en chatberichten plaatsen op reizen die de actieve gebruiker mag bewerken (vereist de Collab-add-on)', - 'admin.plugins.perm.db:write:members': 'Gebruikers aan reizen toevoegen — verleent reistoegang; beveiligd door het ledenbeheer-recht van de actieve gebruiker', - 'admin.plugins.perm.notify:send': 'Een melding (belinbox + e-mail/ntfy) sturen naar de actieve gebruiker of een reis waartoe hij behoort — nooit naar een willekeurige ontvanger', - 'admin.plugins.perm.ai:invoke': 'De door de beheerder geconfigureerde AI-provider (tekstaanvulling + documentextractie) namens de actieve gebruiker gebruiken — de plug-in bezit nooit een sleutel', - 'admin.plugins.perm.oauth:client': 'Namens jou verbinden met een externe dienst via host-bemiddelde OAuth (de host bewaart de tokens, de plug-in ziet ze nooit)', + 'admin.plugins.perm.db:write:todos': + 'Taken aanmaken, bewerken en verwijderen op reizen die de actieve gebruiker mag bewerken', + 'admin.plugins.perm.db:write:atlas': + "Landen en regio's als bezocht markeren en de bucketlist van de actieve gebruiker beheren (vereist de Atlas-add-on)", + 'admin.plugins.perm.db:write:vacay': + 'Vakantiedagen en bedrijfssluitingen op het actieve plan van de actieve gebruiker omschakelen (vereist de Vacay-add-on)', + 'admin.plugins.perm.db:write:journal': + 'Dagboekvermeldingen aanmaken, bewerken en verwijderen in dagboeken die de actieve gebruiker mag bewerken (vereist de Journey-add-on)', + 'admin.plugins.perm.db:write:collections': + 'Collecties aanmaken en bewerken en er plaatsen in opslaan, met de collectierol van de actieve gebruiker (vereist de Collections-add-on)', + 'admin.plugins.perm.db:write:files': + 'Bestanden toevoegen aan reizen die de actieve gebruiker mag bewerken (limiet 10 MB, geblokkeerde extensies geweigerd) en hun koppelingen beheren', + 'admin.plugins.perm.db:write:collab': + 'Notities, peilingen en chatberichten plaatsen op reizen die de actieve gebruiker mag bewerken (vereist de Collab-add-on)', + 'admin.plugins.perm.db:write:members': + 'Gebruikers aan reizen toevoegen — verleent reistoegang; beveiligd door het ledenbeheer-recht van de actieve gebruiker', + 'admin.plugins.perm.notify:send': + 'Een melding (belinbox + e-mail/ntfy) sturen naar de actieve gebruiker of een reis waartoe hij behoort — nooit naar een willekeurige ontvanger', + 'admin.plugins.perm.ai:invoke': + 'De door de beheerder geconfigureerde AI-provider (tekstaanvulling + documentextractie) namens de actieve gebruiker gebruiken — de plug-in bezit nooit een sleutel', + 'admin.plugins.perm.oauth:client': + 'Namens jou verbinden met een externe dienst via host-bemiddelde OAuth (de host bewaart de tokens, de plug-in ziet ze nooit)', 'admin.plugins.perm.db:read:files': 'Bestanden lezen van reizen waartoe de actieve gebruiker toegang heeft', - 'admin.plugins.perm.db:write:reservations': 'Boekingen (reserveringen) aanmaken, bewerken en verwijderen op reizen die de actieve gebruiker mag bewerken', - 'admin.plugins.perm.db:write:accommodations': 'Accommodaties (verblijven) aanmaken, bewerken en verwijderen op reizen die de actieve gebruiker mag bewerken', + 'admin.plugins.perm.db:write:reservations': + 'Boekingen (reserveringen) aanmaken, bewerken en verwijderen op reizen die de actieve gebruiker mag bewerken', + 'admin.plugins.perm.db:write:accommodations': + 'Accommodaties (verblijven) aanmaken, bewerken en verwijderen op reizen die de actieve gebruiker mag bewerken', 'admin.plugins.perm.db:read:journal': 'De reisdagboeken van de actieve gebruiker lezen (vereist de Journey-add-on)', - 'admin.plugins.perm.db:read:atlas': "De door de actieve gebruiker bezochte landen en regio's lezen (vereist de Atlas-add-on)", + 'admin.plugins.perm.db:read:atlas': + "De door de actieve gebruiker bezochte landen en regio's lezen (vereist de Atlas-add-on)", 'admin.plugins.perm.db:read:vacay': 'Het vakantieplan van de actieve gebruiker lezen (vereist de Vacay-add-on)', 'admin.plugins.perm.db:read:daynotes': 'De dagnotities lezen van reizen waartoe de actieve gebruiker toegang heeft', - 'admin.plugins.perm.db:read:collections': 'De collecties met opgeslagen plaatsen van de actieve gebruiker lezen (vereist de Collections-add-on)', - 'admin.plugins.perm.db:write:daynotes': 'Dagnotities aanmaken, bewerken en verwijderen op reizen die de actieve gebruiker mag bewerken', - 'admin.plugins.perm.jobs:run': 'De gedeclareerde achtergrondtaken volgens een schema uitvoeren (zonder gebruikerscontext — kan geen gebruikersgegevens lezen)', + 'admin.plugins.perm.db:read:collections': + 'De collecties met opgeslagen plaatsen van de actieve gebruiker lezen (vereist de Collections-add-on)', + 'admin.plugins.perm.db:write:daynotes': + 'Dagnotities aanmaken, bewerken en verwijderen op reizen die de actieve gebruiker mag bewerken', + 'admin.plugins.perm.jobs:run': + 'De gedeclareerde achtergrondtaken volgens een schema uitvoeren (zonder gebruikerscontext — kan geen gebruikersgegevens lezen)', 'admin.plugins.perm.db:write:costs': 'Uitgaven (budgetposten) aanmaken op reizen die de actieve gebruiker mag bewerken', 'admin.plugins.perm.db:write:places': @@ -283,19 +311,26 @@ const admin: TranslationStrings = { 'Extra details (reviews, beoordelingen, links) aan een plek toevoegen', 'admin.plugins.perm.hook:trip-warning-provider': 'Validatiewaarschuwingen tonen bij een reis (zichtbaar in de planner)', - 'admin.plugins.perm.hook:table-contributor': 'Kolommen en acties toevoegen aan reisweergaven (boekingen, plaatsen, dagen)', - 'admin.plugins.perm.hook:map-marker-provider': "Markeringen aan de reiskaart toevoegen (bijv. boekingen of POI's tonen)", + 'admin.plugins.perm.hook:table-contributor': + 'Kolommen en acties toevoegen aan reisweergaven (boekingen, plaatsen, dagen)', + 'admin.plugins.perm.hook:map-marker-provider': + "Markeringen aan de reiskaart toevoegen (bijv. boekingen of POI's tonen)", 'admin.plugins.perm.hook:pdf-section-provider': 'Tekstsecties toevoegen aan de PDF-export van de reis', 'admin.plugins.perm.hook:atlas-layer-provider': 'Landen markeren op de Atlas-wereldkaart (bijv. wensenlijsten of reisadviezen)', 'admin.plugins.perm.hook:journal-entry-provider': 'Extra regels (links, statistieken) toevoegen aan dagboek-items', - 'admin.plugins.perm.hook:user-data': 'De gegevens die het over een gebruiker opslaat wissen of exporteren (AVG: accountverwijdering en inzageverzoeken)', - 'admin.plugins.perm.hook:trip-card-provider': 'Kleine badges (status, aantallen) aan je dashboard-reiskaarten toevoegen', + 'admin.plugins.perm.hook:user-data': + 'De gegevens die het over een gebruiker opslaat wissen of exporteren (AVG: accountverwijdering en inzageverzoeken)', + 'admin.plugins.perm.hook:trip-card-provider': + 'Kleine badges (status, aantallen) aan je dashboard-reiskaarten toevoegen', + 'admin.plugins.perm.hook:notification-channel': 'Je meldingen via een extra kanaal bezorgen', 'admin.plugins.perm.events:subscribe': 'Reageert op kernactiviteitsgebeurtenissen (alleen gebeurtenisnaam + reis, nooit de inhoud)', 'admin.plugins.perm.http:outbound': 'Uitgaande verzoeken doen naar de opgegeven hosts', - 'admin.plugins.perm.db:read:collab': 'Notities, peilingen en chatberichten lezen van reizen waartoe de actieve gebruiker toegang heeft (vereist de Collab-add-on)', - 'admin.plugins.perm.db:read:files:content': 'De inhoud (bytes) van bestanden lezen van reizen waartoe de actieve gebruiker toegang heeft', + 'admin.plugins.perm.db:read:collab': + 'Notities, peilingen en chatberichten lezen van reizen waartoe de actieve gebruiker toegang heeft (vereist de Collab-add-on)', + 'admin.plugins.perm.db:read:files:content': + 'De inhoud (bytes) van bestanden lezen van reizen waartoe de actieve gebruiker toegang heeft', 'admin.plugins.perm.db:create:trips': 'Nieuwe reizen aanmaken die eigendom zijn van de actieve gebruiker', 'admin.plugins.perm.rates:read': 'De gecachte wisselkoersen van de host lezen', 'admin.plugins.updateConsentTitle': 'Deze update vereist nieuwe machtigingen', @@ -365,6 +400,7 @@ const admin: TranslationStrings = { 'admin.plugins.cap.reservationSlot': 'Reserveringsdetail', 'admin.plugins.cap.replacesTabs': 'Vervangt planner-tabbladen', 'admin.plugins.cap.realtime': 'Realtime updates', + 'admin.plugins.cap.notificationChannel': 'Meldingskanaal', 'admin.plugins.cap.photos': "Levert foto's", 'admin.plugins.cap.calendar': 'Levert kalenderevenementen', 'admin.plugins.cap.placeDetails': 'Verrijkt plekken', diff --git a/shared/src/i18n/nl/settings.ts b/shared/src/i18n/nl/settings.ts index 4808efb10..d2aea4e9c 100644 --- a/shared/src/i18n/nl/settings.ts +++ b/shared/src/i18n/nl/settings.ts @@ -13,7 +13,8 @@ const settings: TranslationStrings = { 'settings.plugins.oauth.disconnect': 'Verbinding verbreken', 'settings.tabs.plugins': 'Plug-ins', 'settings.plugins.title': 'Plug-in-instellingen', - 'settings.plugins.subtitle': 'Je persoonlijke instellingen voor de plug-ins die je gebruikt (API-sleutels, voorkeuren).', + 'settings.plugins.subtitle': + 'Je persoonlijke instellingen voor de plug-ins die je gebruikt (API-sleutels, voorkeuren).', 'settings.plugins.empty': 'Er zijn geen plug-ins actief.', 'settings.plugins.saved': 'Instellingen opgeslagen', 'settings.tabs.account': 'Account', @@ -288,6 +289,15 @@ const settings: TranslationStrings = { 'settings.ntfyUrl.testFailed': 'Test-Ntfy-melding mislukt', 'settings.ntfyUrl.tokenCleared': 'Toegangstoken gewist', 'settings.notificationPreferences.inapp': 'In-App', + 'settings.notificationPreferences.notConfigured': + 'Nog niet ingesteld — configureer het in de instellingen van de plug-in', + 'settings.plugins.actions': 'Acties', + 'settings.plugins.actions.confirm': 'Deze actie uitvoeren?', + 'settings.notificationPreferences.sendTest': 'Test versturen', + 'settings.notificationPreferences.configure': 'Instellen', + 'settings.notificationPreferences.testSuccess': 'Testmelding verstuurd.', + 'settings.notificationPreferences.testFailed': 'Test mislukt.', + 'settings.notificationPreferences.pluginConfigured': 'Ingesteld. Beheer je gegevens op de instellingenpagina van de plug-in.', 'settings.notificationPreferences.webhook': 'Webhook', 'settings.notificationPreferences.email': 'Email', 'settings.notificationPreferences.ntfy': 'Ntfy', diff --git a/shared/src/i18n/pl/admin.ts b/shared/src/i18n/pl/admin.ts index 1c1a96519..af3cc17e4 100644 --- a/shared/src/i18n/pl/admin.ts +++ b/shared/src/i18n/pl/admin.ts @@ -162,7 +162,8 @@ const admin: TranslationStrings = { 'admin.plugins.uploaded': 'Wtyczka “{name}” przesłana — aktywuj ją, aby uruchomić', 'admin.plugins.sideloaded': 'Załadowano ręcznie', 'admin.plugins.devLinkBadge': 'Dev-Link', - 'admin.plugins.devLinkHint': 'Wczytany z lokalnego katalogu kompilacji i przeładowywany na gorąco na prawdziwych danych — tylko dla programistów', + 'admin.plugins.devLinkHint': + 'Wczytany z lokalnego katalogu kompilacji i przeładowywany na gorąco na prawdziwych danych — tylko dla programistów', 'admin.plugins.devLinkTitle': 'Podłącz lokalny plugin', 'admin.plugins.devLinkPathPlaceholder': '/absolute/path/to/your/plugin', 'admin.plugins.devLinkButton': 'Podłącz', @@ -179,6 +180,15 @@ const admin: TranslationStrings = { 'admin.plugins.deactivated': 'Wtyczka została dezaktywowana', 'admin.plugins.viewErrors': 'Pokaż dziennik błędów', 'admin.plugins.errorLog': 'Dziennik błędów', + 'admin.plugins.allowedHosts': 'Dozwolone hosty', + 'admin.plugins.allowedHosts.hint': 'Ta wtyczka łączy się z usługą, którą tylko Ty możesz wskazać (serwer self-hosted). Dodaj hosty, do których może sięgać — do innych nie sięgnie.', + 'admin.plugins.allowedHosts.none': 'Nie dodano jeszcze żadnych hostów.', + 'admin.plugins.allowedHosts.unsupported': 'Ta wtyczka nie używa hostów podanych przez operatora. Jej dozwolone hosty są zapisane w manifeście.', + 'admin.plugins.allowedHosts.restartNote': 'Zapis restartuje wtyczkę, aby pobrała nową listę.', + 'admin.plugins.allowedHosts.add': 'Dodaj dozwolony host', + 'admin.plugins.allowedHosts.count': '{n} dozwolonych hostów', + 'admin.plugins.operatorEgressPill': '+ hosty, które dodasz', + 'admin.plugins.operatorEgressHint': 'Ta wtyczka łączy się z usługą, którą tylko Ty możesz wskazać (serwer self-hosted). Po instalacji dodaj hosty w ⋯ → Dozwolone hosty. Do innych nie sięgnie.', 'admin.plugins.noErrors': 'Brak zarejestrowanych błędów.', 'admin.plugins.uninstalled': 'Wtyczka została odinstalowana', 'admin.plugins.uninstallTitle': 'Odinstalować wtyczkę?', @@ -231,33 +241,52 @@ const admin: TranslationStrings = { 'admin.plugins.perm.db:read:costs': 'Odczyt wydatków (pozycji budżetu), do których działający użytkownik ma dostęp', 'admin.plugins.perm.db:read:packing': 'Odczyt list rzeczy do spakowania w podróżach, do których działający użytkownik ma dostęp', - 'admin.plugins.perm.db:write:packing': 'Tworzenie, edytowanie i usuwanie pozycji listy pakowania w podróżach, które działający użytkownik może edytować', + 'admin.plugins.perm.db:write:packing': + 'Tworzenie, edytowanie i usuwanie pozycji listy pakowania w podróżach, które działający użytkownik może edytować', 'admin.plugins.perm.weather:read': 'Odczyt zapisanej w pamięci prognozy pogody hosta (wg współrzędnych)', 'admin.plugins.perm.db:read:categories': 'Odczyt globalnej listy kategorii miejsc', 'admin.plugins.perm.db:read:tags': 'Odczyt tagów działającego użytkownika', 'admin.plugins.perm.db:write:tags': 'Tworzenie, edytowanie i usuwanie tagów działającego użytkownika', 'admin.plugins.perm.db:read:todos': 'Odczyt zadań w podróżach, do których działający użytkownik ma dostęp', - 'admin.plugins.perm.db:write:todos': 'Tworzenie, edytowanie i usuwanie zadań w podróżach, które działający użytkownik może edytować', - 'admin.plugins.perm.db:write:atlas': 'Oznaczanie krajów i regionów jako odwiedzonych oraz zarządzanie listą marzeń działającego użytkownika (wymaga dodatku Atlas)', - 'admin.plugins.perm.db:write:vacay': 'Przełączanie dni urlopu i świąt firmowych w aktywnym planie działającego użytkownika (wymaga dodatku Vacay)', - 'admin.plugins.perm.db:write:journal': 'Tworzenie, edytowanie i usuwanie wpisów w dziennikach, które działający użytkownik może edytować (wymaga dodatku Journey)', - 'admin.plugins.perm.db:write:collections': 'Tworzenie i edytowanie kolekcji oraz zapisywanie w nich miejsc, z rolą działającego użytkownika w kolekcji (wymaga dodatku Collections)', - 'admin.plugins.perm.db:write:files': 'Dołączanie plików do podróży, które działający użytkownik może edytować (limit 10 MB, zablokowane rozszerzenia odrzucane), i zarządzanie ich powiązaniami', - 'admin.plugins.perm.db:write:collab': 'Publikowanie notatek, ankiet i wiadomości czatu w podróżach, które działający użytkownik może edytować (wymaga dodatku Collab)', - 'admin.plugins.perm.db:write:members': 'Dodawanie użytkowników do podróży — przyznaje dostęp do podróży; chronione uprawnieniem zarządzania członkami działającego użytkownika', - 'admin.plugins.perm.notify:send': 'Wysyłanie powiadomienia (skrzynka dzwonka + e-mail/ntfy) do działającego użytkownika lub jego podróży — nigdy do dowolnego odbiorcy', - 'admin.plugins.perm.ai:invoke': 'Korzystanie ze skonfigurowanego przez administratora dostawcy AI (uzupełnianie tekstu + wyodrębnianie z dokumentów) w imieniu działającego użytkownika — wtyczka nigdy nie przechowuje klucza', - 'admin.plugins.perm.oauth:client': 'Łączenie się z usługą zewnętrzną w Twoim imieniu przez OAuth pośredniczony przez hosta (host przechowuje tokeny, wtyczka ich nie widzi)', + 'admin.plugins.perm.db:write:todos': + 'Tworzenie, edytowanie i usuwanie zadań w podróżach, które działający użytkownik może edytować', + 'admin.plugins.perm.db:write:atlas': + 'Oznaczanie krajów i regionów jako odwiedzonych oraz zarządzanie listą marzeń działającego użytkownika (wymaga dodatku Atlas)', + 'admin.plugins.perm.db:write:vacay': + 'Przełączanie dni urlopu i świąt firmowych w aktywnym planie działającego użytkownika (wymaga dodatku Vacay)', + 'admin.plugins.perm.db:write:journal': + 'Tworzenie, edytowanie i usuwanie wpisów w dziennikach, które działający użytkownik może edytować (wymaga dodatku Journey)', + 'admin.plugins.perm.db:write:collections': + 'Tworzenie i edytowanie kolekcji oraz zapisywanie w nich miejsc, z rolą działającego użytkownika w kolekcji (wymaga dodatku Collections)', + 'admin.plugins.perm.db:write:files': + 'Dołączanie plików do podróży, które działający użytkownik może edytować (limit 10 MB, zablokowane rozszerzenia odrzucane), i zarządzanie ich powiązaniami', + 'admin.plugins.perm.db:write:collab': + 'Publikowanie notatek, ankiet i wiadomości czatu w podróżach, które działający użytkownik może edytować (wymaga dodatku Collab)', + 'admin.plugins.perm.db:write:members': + 'Dodawanie użytkowników do podróży — przyznaje dostęp do podróży; chronione uprawnieniem zarządzania członkami działającego użytkownika', + 'admin.plugins.perm.notify:send': + 'Wysyłanie powiadomienia (skrzynka dzwonka + e-mail/ntfy) do działającego użytkownika lub jego podróży — nigdy do dowolnego odbiorcy', + 'admin.plugins.perm.ai:invoke': + 'Korzystanie ze skonfigurowanego przez administratora dostawcy AI (uzupełnianie tekstu + wyodrębnianie z dokumentów) w imieniu działającego użytkownika — wtyczka nigdy nie przechowuje klucza', + 'admin.plugins.perm.oauth:client': + 'Łączenie się z usługą zewnętrzną w Twoim imieniu przez OAuth pośredniczony przez hosta (host przechowuje tokeny, wtyczka ich nie widzi)', 'admin.plugins.perm.db:read:files': 'Odczyt plików w podróżach, do których działający użytkownik ma dostęp', - 'admin.plugins.perm.db:write:reservations': 'Tworzenie, edytowanie i usuwanie rezerwacji w podróżach, które działający użytkownik może edytować', - 'admin.plugins.perm.db:write:accommodations': 'Tworzenie, edytowanie i usuwanie zakwaterowań w podróżach, które działający użytkownik może edytować', + 'admin.plugins.perm.db:write:reservations': + 'Tworzenie, edytowanie i usuwanie rezerwacji w podróżach, które działający użytkownik może edytować', + 'admin.plugins.perm.db:write:accommodations': + 'Tworzenie, edytowanie i usuwanie zakwaterowań w podróżach, które działający użytkownik może edytować', 'admin.plugins.perm.db:read:journal': 'Odczyt dzienników podróży działającego użytkownika (wymaga dodatku Journey)', - 'admin.plugins.perm.db:read:atlas': 'Odczyt krajów i regionów odwiedzonych przez działającego użytkownika (wymaga dodatku Atlas)', + 'admin.plugins.perm.db:read:atlas': + 'Odczyt krajów i regionów odwiedzonych przez działającego użytkownika (wymaga dodatku Atlas)', 'admin.plugins.perm.db:read:vacay': 'Odczyt planu urlopu działającego użytkownika (wymaga dodatku Vacay)', - 'admin.plugins.perm.db:read:daynotes': 'Odczyt notatek dziennych w podróżach, do których działający użytkownik ma dostęp', - 'admin.plugins.perm.db:read:collections': 'Odczyt kolekcji zapisanych miejsc działającego użytkownika (wymaga dodatku Collections)', - 'admin.plugins.perm.db:write:daynotes': 'Tworzenie, edytowanie i usuwanie notatek dziennych w podróżach, które działający użytkownik może edytować', - 'admin.plugins.perm.jobs:run': 'Uruchamianie zadeklarowanych zadań w tle według harmonogramu (bez kontekstu użytkownika — nie może odczytywać danych użytkownika)', + 'admin.plugins.perm.db:read:daynotes': + 'Odczyt notatek dziennych w podróżach, do których działający użytkownik ma dostęp', + 'admin.plugins.perm.db:read:collections': + 'Odczyt kolekcji zapisanych miejsc działającego użytkownika (wymaga dodatku Collections)', + 'admin.plugins.perm.db:write:daynotes': + 'Tworzenie, edytowanie i usuwanie notatek dziennych w podróżach, które działający użytkownik może edytować', + 'admin.plugins.perm.jobs:run': + 'Uruchamianie zadeklarowanych zadań w tle według harmonogramu (bez kontekstu użytkownika — nie może odczytywać danych użytkownika)', 'admin.plugins.perm.db:write:costs': 'Tworzenie wydatków (pozycji budżetu) w podróżach, które działający użytkownik może edytować', 'admin.plugins.perm.db:write:places': @@ -279,19 +308,25 @@ const admin: TranslationStrings = { 'admin.plugins.perm.hook:trip-warning-provider': 'Zgłaszanie ostrzeżeń walidacyjnych dla podróży (widocznych w planerze)', 'admin.plugins.perm.hook:table-contributor': 'Dodawanie kolumn i akcji do widoków podróży (rezerwacje, miejsca, dni)', - 'admin.plugins.perm.hook:map-marker-provider': 'Dodawanie znaczników do mapy podróży (np. pokazywanie rezerwacji lub POI)', + 'admin.plugins.perm.hook:map-marker-provider': + 'Dodawanie znaczników do mapy podróży (np. pokazywanie rezerwacji lub POI)', 'admin.plugins.perm.hook:pdf-section-provider': 'Dołączanie sekcji tekstowych do eksportu PDF podróży', 'admin.plugins.perm.hook:atlas-layer-provider': 'Wyróżnianie krajów na mapie świata Atlasu (np. listy życzeń lub ostrzeżenia dla podróżnych)', 'admin.plugins.perm.hook:journal-entry-provider': 'Dodawanie dodatkowych wierszy (linki, statystyki) do wpisów dziennika', - 'admin.plugins.perm.hook:user-data': 'Usuwanie lub eksport przechowywanych danych o użytkowniku (RODO: usunięcie konta i wnioski o dane)', - 'admin.plugins.perm.hook:trip-card-provider': 'Dodawanie małych plakietek (status, liczniki) do kart podróży na pulpicie', + 'admin.plugins.perm.hook:user-data': + 'Usuwanie lub eksport przechowywanych danych o użytkowniku (RODO: usunięcie konta i wnioski o dane)', + 'admin.plugins.perm.hook:trip-card-provider': + 'Dodawanie małych plakietek (status, liczniki) do kart podróży na pulpicie', + 'admin.plugins.perm.hook:notification-channel': 'Dostarczać Twoje powiadomienia dodatkowym kanałem', 'admin.plugins.perm.events:subscribe': 'Reagowanie na podstawowe zdarzenia aktywności (tylko nazwa zdarzenia i podróż, nigdy treść)', 'admin.plugins.perm.http:outbound': 'Wykonywanie wychodzących zapytań do zadeklarowanych hostów', - 'admin.plugins.perm.db:read:collab': 'Odczyt notatek, ankiet i wiadomości czatu w podróżach, do których działający użytkownik ma dostęp (wymaga dodatku Collab)', - 'admin.plugins.perm.db:read:files:content': 'Odczyt zawartości (bajtów) plików w podróżach, do których działający użytkownik ma dostęp', + 'admin.plugins.perm.db:read:collab': + 'Odczyt notatek, ankiet i wiadomości czatu w podróżach, do których działający użytkownik ma dostęp (wymaga dodatku Collab)', + 'admin.plugins.perm.db:read:files:content': + 'Odczyt zawartości (bajtów) plików w podróżach, do których działający użytkownik ma dostęp', 'admin.plugins.perm.db:create:trips': 'Tworzenie nowych podróży należących do działającego użytkownika', 'admin.plugins.perm.rates:read': 'Odczyt zapisanych w pamięci kursów walut hosta', 'admin.plugins.updateConsentTitle': 'Ta aktualizacja wymaga nowych uprawnień', @@ -362,6 +397,7 @@ const admin: TranslationStrings = { 'admin.plugins.cap.reservationSlot': 'Szczegóły rezerwacji', 'admin.plugins.cap.replacesTabs': 'Zastępuje karty planera', 'admin.plugins.cap.realtime': 'Aktualizacje na żywo', + 'admin.plugins.cap.notificationChannel': 'Kanał powiadomień', 'admin.plugins.cap.photos': 'Dostarcza zdjęcia', 'admin.plugins.cap.calendar': 'Dostarcza wydarzenia kalendarza', 'admin.plugins.cap.placeDetails': 'Wzbogaca miejsca', diff --git a/shared/src/i18n/pl/settings.ts b/shared/src/i18n/pl/settings.ts index 64af71458..2414c857d 100644 --- a/shared/src/i18n/pl/settings.ts +++ b/shared/src/i18n/pl/settings.ts @@ -284,6 +284,14 @@ const settings: TranslationStrings = { 'settings.ntfyUrl.testFailed': 'Testowe powiadomienie Ntfy nie powiodło się', 'settings.ntfyUrl.tokenCleared': 'Token dostępu wyczyszczony', 'settings.notificationPreferences.inapp': 'In-App', + 'settings.notificationPreferences.notConfigured': 'Jeszcze nieskonfigurowane — ustaw to w ustawieniach wtyczki', + 'settings.plugins.actions': 'Akcje', + 'settings.plugins.actions.confirm': 'Wykonać tę akcję?', + 'settings.notificationPreferences.sendTest': 'Wyślij test', + 'settings.notificationPreferences.configure': 'Skonfiguruj', + 'settings.notificationPreferences.testSuccess': 'Wysłano powiadomienie testowe.', + 'settings.notificationPreferences.testFailed': 'Test nie powiódł się.', + 'settings.notificationPreferences.pluginConfigured': 'Skonfigurowano. Dane logowania zmienisz na stronie ustawień wtyczki.', 'settings.notificationPreferences.webhook': 'Webhook', 'settings.notificationPreferences.email': 'Email', 'settings.notificationPreferences.ntfy': 'Ntfy', diff --git a/shared/src/i18n/ru/admin.ts b/shared/src/i18n/ru/admin.ts index 1a1f40ca1..0d37c0069 100644 --- a/shared/src/i18n/ru/admin.ts +++ b/shared/src/i18n/ru/admin.ts @@ -169,7 +169,8 @@ const admin: TranslationStrings = { 'admin.plugins.uploaded': 'Плагин “{name}” загружен — активируйте для запуска', 'admin.plugins.sideloaded': 'Загружен вручную', 'admin.plugins.devLinkBadge': 'Dev-Link', - 'admin.plugins.devLinkHint': 'Загружено из локального каталога сборки и горячо перезагружается с реальными данными — только для разработки', + 'admin.plugins.devLinkHint': + 'Загружено из локального каталога сборки и горячо перезагружается с реальными данными — только для разработки', 'admin.plugins.devLinkTitle': 'Подключить локальный плагин', 'admin.plugins.devLinkPathPlaceholder': '/absolute/path/to/your/plugin', 'admin.plugins.devLinkButton': 'Подключить', @@ -186,6 +187,15 @@ const admin: TranslationStrings = { 'admin.plugins.deactivated': 'Плагин деактивирован', 'admin.plugins.viewErrors': 'Показать журнал ошибок', 'admin.plugins.errorLog': 'Журнал ошибок', + 'admin.plugins.allowedHosts': 'Разрешённые хосты', + 'admin.plugins.allowedHosts.hint': 'Этот плагин обращается к сервису, который можете указать только вы (self-hosted сервер). Добавьте хосты, к которым он может обращаться — к другим он не сможет.', + 'admin.plugins.allowedHosts.none': 'Хосты пока не добавлены.', + 'admin.plugins.allowedHosts.unsupported': 'Этот плагин не использует хосты, задаваемые оператором. Его разрешённые хосты зафиксированы в манифесте.', + 'admin.plugins.allowedHosts.restartNote': 'Сохранение перезапускает плагин, чтобы он подхватил новый список.', + 'admin.plugins.allowedHosts.add': 'Добавить разрешённый хост', + 'admin.plugins.allowedHosts.count': 'Разрешённых хостов: {n}', + 'admin.plugins.operatorEgressPill': '+ добавленные вами хосты', + 'admin.plugins.operatorEgressHint': 'Этот плагин обращается к сервису, который можете указать только вы (self-hosted сервер). После установки добавьте хосты в ⋯ → Разрешённые хосты. К другим он не сможет обратиться.', 'admin.plugins.noErrors': 'Ошибок не зафиксировано.', 'admin.plugins.uninstalled': 'Плагин удалён', 'admin.plugins.uninstallTitle': 'Удалить плагин?', @@ -236,33 +246,52 @@ const admin: TranslationStrings = { 'admin.plugins.perm.db:read:users': 'Читать основную информацию профиля (имя, аватар — но не учётные данные)', 'admin.plugins.perm.db:read:costs': 'Читать расходы (статьи бюджета), доступные текущему пользователю', 'admin.plugins.perm.db:read:packing': 'Читать списки вещей поездок, доступных текущему пользователю', - 'admin.plugins.perm.db:write:packing': 'Создавать, редактировать и удалять пункты списка вещей в поездках, которые текущий пользователь может редактировать', + 'admin.plugins.perm.db:write:packing': + 'Создавать, редактировать и удалять пункты списка вещей в поездках, которые текущий пользователь может редактировать', 'admin.plugins.perm.weather:read': 'Читать кэшированный прогноз погоды хоста (по координатам)', 'admin.plugins.perm.db:read:categories': 'Читать глобальный список категорий мест', 'admin.plugins.perm.db:read:tags': 'Читать теги текущего пользователя', 'admin.plugins.perm.db:write:tags': 'Создавать, редактировать и удалять теги текущего пользователя', 'admin.plugins.perm.db:read:todos': 'Читать задачи поездок, доступных текущему пользователю', - 'admin.plugins.perm.db:write:todos': 'Создавать, редактировать и удалять задачи в поездках, которые текущий пользователь может редактировать', - 'admin.plugins.perm.db:write:atlas': 'Отмечать страны и регионы как посещённые и управлять списком желаний текущего пользователя (требуется дополнение Atlas)', - 'admin.plugins.perm.db:write:vacay': 'Переключать дни отпуска и корпоративные праздники в активном плане текущего пользователя (требуется дополнение Vacay)', - 'admin.plugins.perm.db:write:journal': 'Создавать, редактировать и удалять записи в дневниках, которые текущий пользователь может редактировать (требуется дополнение Journey)', - 'admin.plugins.perm.db:write:collections': 'Создавать и редактировать коллекции и сохранять в них места с ролью текущего пользователя в коллекции (требуется дополнение Collections)', - 'admin.plugins.perm.db:write:files': 'Прикреплять файлы к поездкам, которые текущий пользователь может редактировать (лимит 10 МБ, заблокированные расширения отклоняются), и управлять их связями', - 'admin.plugins.perm.db:write:collab': 'Публиковать заметки, опросы и сообщения чата в поездках, которые текущий пользователь может редактировать (требуется дополнение Collab)', - 'admin.plugins.perm.db:write:members': 'Добавлять пользователей в поездки — предоставляет доступ к поездке; защищено правом управления участниками текущего пользователя', - 'admin.plugins.perm.notify:send': 'Отправлять уведомление (колокольчик + email/ntfy) текущему пользователю или его поездке — никогда произвольному получателю', - 'admin.plugins.perm.ai:invoke': 'Использовать настроенного администратором ИИ-провайдера (дополнение текста + извлечение из документов) от имени текущего пользователя — плагин никогда не хранит ключ', - 'admin.plugins.perm.oauth:client': 'Подключаться к стороннему сервису от вашего имени через OAuth, посредником которого выступает хост (хост хранит токены, плагин их не видит)', + 'admin.plugins.perm.db:write:todos': + 'Создавать, редактировать и удалять задачи в поездках, которые текущий пользователь может редактировать', + 'admin.plugins.perm.db:write:atlas': + 'Отмечать страны и регионы как посещённые и управлять списком желаний текущего пользователя (требуется дополнение Atlas)', + 'admin.plugins.perm.db:write:vacay': + 'Переключать дни отпуска и корпоративные праздники в активном плане текущего пользователя (требуется дополнение Vacay)', + 'admin.plugins.perm.db:write:journal': + 'Создавать, редактировать и удалять записи в дневниках, которые текущий пользователь может редактировать (требуется дополнение Journey)', + 'admin.plugins.perm.db:write:collections': + 'Создавать и редактировать коллекции и сохранять в них места с ролью текущего пользователя в коллекции (требуется дополнение Collections)', + 'admin.plugins.perm.db:write:files': + 'Прикреплять файлы к поездкам, которые текущий пользователь может редактировать (лимит 10 МБ, заблокированные расширения отклоняются), и управлять их связями', + 'admin.plugins.perm.db:write:collab': + 'Публиковать заметки, опросы и сообщения чата в поездках, которые текущий пользователь может редактировать (требуется дополнение Collab)', + 'admin.plugins.perm.db:write:members': + 'Добавлять пользователей в поездки — предоставляет доступ к поездке; защищено правом управления участниками текущего пользователя', + 'admin.plugins.perm.notify:send': + 'Отправлять уведомление (колокольчик + email/ntfy) текущему пользователю или его поездке — никогда произвольному получателю', + 'admin.plugins.perm.ai:invoke': + 'Использовать настроенного администратором ИИ-провайдера (дополнение текста + извлечение из документов) от имени текущего пользователя — плагин никогда не хранит ключ', + 'admin.plugins.perm.oauth:client': + 'Подключаться к стороннему сервису от вашего имени через OAuth, посредником которого выступает хост (хост хранит токены, плагин их не видит)', 'admin.plugins.perm.db:read:files': 'Читать файлы поездок, доступных текущему пользователю', - 'admin.plugins.perm.db:write:reservations': 'Создавать, редактировать и удалять бронирования в поездках, которые текущий пользователь может редактировать', - 'admin.plugins.perm.db:write:accommodations': 'Создавать, редактировать и удалять размещения в поездках, которые текущий пользователь может редактировать', - 'admin.plugins.perm.db:read:journal': 'Читать дневники путешествий текущего пользователя (требуется дополнение Journey)', - 'admin.plugins.perm.db:read:atlas': 'Читать посещённые страны и регионы текущего пользователя (требуется дополнение Atlas)', + 'admin.plugins.perm.db:write:reservations': + 'Создавать, редактировать и удалять бронирования в поездках, которые текущий пользователь может редактировать', + 'admin.plugins.perm.db:write:accommodations': + 'Создавать, редактировать и удалять размещения в поездках, которые текущий пользователь может редактировать', + 'admin.plugins.perm.db:read:journal': + 'Читать дневники путешествий текущего пользователя (требуется дополнение Journey)', + 'admin.plugins.perm.db:read:atlas': + 'Читать посещённые страны и регионы текущего пользователя (требуется дополнение Atlas)', 'admin.plugins.perm.db:read:vacay': 'Читать план отпуска текущего пользователя (требуется дополнение Vacay)', 'admin.plugins.perm.db:read:daynotes': 'Читать заметки дня поездок, доступных текущему пользователю', - 'admin.plugins.perm.db:read:collections': 'Читать коллекции сохранённых мест текущего пользователя (требуется дополнение Collections)', - 'admin.plugins.perm.db:write:daynotes': 'Создавать, редактировать и удалять заметки дня в поездках, которые текущий пользователь может редактировать', - 'admin.plugins.perm.jobs:run': 'Запускать объявленные фоновые задачи по расписанию (без контекста пользователя — не может читать данные пользователя)', + 'admin.plugins.perm.db:read:collections': + 'Читать коллекции сохранённых мест текущего пользователя (требуется дополнение Collections)', + 'admin.plugins.perm.db:write:daynotes': + 'Создавать, редактировать и удалять заметки дня в поездках, которые текущий пользователь может редактировать', + 'admin.plugins.perm.jobs:run': + 'Запускать объявленные фоновые задачи по расписанию (без контекста пользователя — не может читать данные пользователя)', 'admin.plugins.perm.db:write:costs': 'Создавать расходы (статьи бюджета) в поездках, которые текущий пользователь может редактировать', 'admin.plugins.perm.db:write:places': @@ -282,20 +311,27 @@ const admin: TranslationStrings = { 'admin.plugins.perm.hook:place-detail-provider': 'Добавлять дополнительные сведения (отзывы, оценки, ссылки) к месту', 'admin.plugins.perm.hook:trip-warning-provider': 'Выдавать предупреждения проверки для поездки (отображаются в планировщике)', - 'admin.plugins.perm.hook:table-contributor': 'Добавлять столбцы и действия в представления поездки (бронирования, места, дни)', - 'admin.plugins.perm.hook:map-marker-provider': 'Добавлять маркеры на карту поездки (например, показывать бронирования или POI)', + 'admin.plugins.perm.hook:table-contributor': + 'Добавлять столбцы и действия в представления поездки (бронирования, места, дни)', + 'admin.plugins.perm.hook:map-marker-provider': + 'Добавлять маркеры на карту поездки (например, показывать бронирования или POI)', 'admin.plugins.perm.hook:pdf-section-provider': 'Добавлять текстовые разделы в PDF-экспорт поездки', 'admin.plugins.perm.hook:atlas-layer-provider': 'Выделять страны на карте мира в Атласе (например, списки желаний или предупреждения для путешественников)', 'admin.plugins.perm.hook:journal-entry-provider': 'Добавлять дополнительные строки (ссылки, статистику) к записям дневника', - 'admin.plugins.perm.hook:user-data': 'Удаление или экспорт хранимых данных о пользователе (GDPR: удаление аккаунта и запросы данных)', - 'admin.plugins.perm.hook:trip-card-provider': 'Добавлять небольшие значки (статус, счётчики) на карточки поездок на панели', + 'admin.plugins.perm.hook:user-data': + 'Удаление или экспорт хранимых данных о пользователе (GDPR: удаление аккаунта и запросы данных)', + 'admin.plugins.perm.hook:trip-card-provider': + 'Добавлять небольшие значки (статус, счётчики) на карточки поездок на панели', + 'admin.plugins.perm.hook:notification-channel': 'Доставлять ваши уведомления через дополнительный канал', 'admin.plugins.perm.events:subscribe': 'Реагировать на основные события активности (только название события и поездка, никогда содержимое)', 'admin.plugins.perm.http:outbound': 'Выполнять исходящие запросы к заявленным хостам', - 'admin.plugins.perm.db:read:collab': 'Читать заметки, опросы и сообщения чата поездок, доступных текущему пользователю (требуется дополнение Collab)', - 'admin.plugins.perm.db:read:files:content': 'Читать содержимое (байты) файлов поездок, доступных текущему пользователю', + 'admin.plugins.perm.db:read:collab': + 'Читать заметки, опросы и сообщения чата поездок, доступных текущему пользователю (требуется дополнение Collab)', + 'admin.plugins.perm.db:read:files:content': + 'Читать содержимое (байты) файлов поездок, доступных текущему пользователю', 'admin.plugins.perm.db:create:trips': 'Создавать новые поездки, принадлежащие текущему пользователю', 'admin.plugins.perm.rates:read': 'Читать кэшированные курсы валют хоста', 'admin.plugins.updateConsentTitle': 'Для этого обновления нужны новые разрешения', @@ -365,6 +401,7 @@ const admin: TranslationStrings = { 'admin.plugins.cap.reservationSlot': 'Детали брони', 'admin.plugins.cap.replacesTabs': 'Заменяет вкладки планировщика', 'admin.plugins.cap.realtime': 'Обновления в реальном времени', + 'admin.plugins.cap.notificationChannel': 'Канал уведомлений', 'admin.plugins.cap.photos': 'Предоставляет фото', 'admin.plugins.cap.calendar': 'Предоставляет события календаря', 'admin.plugins.cap.placeDetails': 'Обогащает места', diff --git a/shared/src/i18n/ru/settings.ts b/shared/src/i18n/ru/settings.ts index 0811d0b1d..62e3c1dbf 100644 --- a/shared/src/i18n/ru/settings.ts +++ b/shared/src/i18n/ru/settings.ts @@ -289,6 +289,14 @@ const settings: TranslationStrings = { 'settings.ntfyUrl.testFailed': 'Ошибка отправки тестового уведомления Ntfy', 'settings.ntfyUrl.tokenCleared': 'Токен доступа очищен', 'settings.notificationPreferences.inapp': 'In-App', + 'settings.notificationPreferences.notConfigured': 'Ещё не настроено — настройте в параметрах плагина', + 'settings.plugins.actions': 'Действия', + 'settings.plugins.actions.confirm': 'Выполнить это действие?', + 'settings.notificationPreferences.sendTest': 'Отправить тест', + 'settings.notificationPreferences.configure': 'Настроить', + 'settings.notificationPreferences.testSuccess': 'Тестовое уведомление отправлено.', + 'settings.notificationPreferences.testFailed': 'Тест не удался.', + 'settings.notificationPreferences.pluginConfigured': 'Настроено. Учётные данные — на странице настроек плагина.', 'settings.notificationPreferences.webhook': 'Webhook', 'settings.notificationPreferences.email': 'Email', 'settings.notificationPreferences.ntfy': 'Ntfy', diff --git a/shared/src/i18n/sv/admin.ts b/shared/src/i18n/sv/admin.ts index d78a673fe..c8c95a8a4 100644 --- a/shared/src/i18n/sv/admin.ts +++ b/shared/src/i18n/sv/admin.ts @@ -225,7 +225,8 @@ const admin: TranslationStrings = { 'admin.plugins.uploaded': 'Pluginet “{name}” uppladdat — aktivera det för att köra', 'admin.plugins.sideloaded': 'Manuellt laddad', 'admin.plugins.devLinkBadge': 'Dev-Link', - 'admin.plugins.devLinkHint': 'Laddad från en lokal byggkatalog och hot-reload mot riktig data — endast för utveckling', + 'admin.plugins.devLinkHint': + 'Laddad från en lokal byggkatalog och hot-reload mot riktig data — endast för utveckling', 'admin.plugins.devLinkTitle': 'Länka ett lokalt plugin', 'admin.plugins.devLinkPathPlaceholder': '/absolute/path/to/your/plugin', 'admin.plugins.devLinkButton': 'Länka', @@ -242,6 +243,15 @@ const admin: TranslationStrings = { 'admin.plugins.deactivated': 'Pluginet har inaktiverats', 'admin.plugins.viewErrors': 'Visa felloggen', 'admin.plugins.errorLog': 'Fellogg', + 'admin.plugins.allowedHosts': 'Tillåtna värdar', + 'admin.plugins.allowedHosts.hint': 'Detta plugin pratar med en tjänst som bara du kan namnge (en självhostad server). Lägg till de värdar det får nå — inga andra.', + 'admin.plugins.allowedHosts.none': 'Inga värdar tillagda ännu.', + 'admin.plugins.allowedHosts.unsupported': 'Detta plugin använder inte operatörsangivna värdar. Dess tillåtna värdar är låsta i manifestet.', + 'admin.plugins.allowedHosts.restartNote': 'Att spara startar om pluginet så att den nya listan används.', + 'admin.plugins.allowedHosts.add': 'Lägg till tillåten värd', + 'admin.plugins.allowedHosts.count': '{n} tillåtna värdar', + 'admin.plugins.operatorEgressPill': '+ värdar du lägger till', + 'admin.plugins.operatorEgressHint': 'Detta plugin pratar med en tjänst som bara du kan namnge (en självhostad server). Lägg efter installation till värdarna under ⋯ → Tillåtna värdar. Inga andra nås.', 'admin.plugins.noErrors': 'Inga fel loggade.', 'admin.plugins.uninstalled': 'Pluginet har avinstallerats', 'admin.plugins.uninstallTitle': 'Avinstallera pluginet?', @@ -293,33 +303,51 @@ const admin: TranslationStrings = { 'Läsa grundläggande profilinformation (namn, avatar – aldrig inloggningsuppgifter)', 'admin.plugins.perm.db:read:costs': 'Läsa kostnader (budgetposter) som den aktiva användaren har åtkomst till', 'admin.plugins.perm.db:read:packing': 'Läsa packlistor för resor som den aktiva användaren har åtkomst till', - 'admin.plugins.perm.db:write:packing': 'Skapa, redigera och ta bort packlisteposter på resor som den aktiva användaren kan redigera', + 'admin.plugins.perm.db:write:packing': + 'Skapa, redigera och ta bort packlisteposter på resor som den aktiva användaren kan redigera', 'admin.plugins.perm.weather:read': 'Läsa värdens cachade väderprognos (efter koordinater)', 'admin.plugins.perm.db:read:categories': 'Läsa den globala listan över platskategorier', 'admin.plugins.perm.db:read:tags': 'Läsa den aktiva användarens taggar', 'admin.plugins.perm.db:write:tags': 'Skapa, redigera och ta bort den aktiva användarens taggar', 'admin.plugins.perm.db:read:todos': 'Läsa uppgifter för resor som den aktiva användaren har åtkomst till', - 'admin.plugins.perm.db:write:todos': 'Skapa, redigera och ta bort uppgifter på resor som den aktiva användaren kan redigera', - 'admin.plugins.perm.db:write:atlas': 'Markera länder och regioner som besökta och hantera den aktiva användarens bucketlista (kräver tillägget Atlas)', - 'admin.plugins.perm.db:write:vacay': 'Växla semesterdagar och företagshelgdagar i den aktiva användarens aktiva plan (kräver tillägget Vacay)', - 'admin.plugins.perm.db:write:journal': 'Skapa, redigera och ta bort dagboksinlägg i dagböcker som den aktiva användaren kan redigera (kräver tillägget Journey)', - 'admin.plugins.perm.db:write:collections': 'Skapa och redigera samlingar och spara platser i dem, med den aktiva användarens samlingsroll (kräver tillägget Collections)', - 'admin.plugins.perm.db:write:files': 'Bifoga filer till resor som den aktiva användaren kan redigera (10 MB-gräns, blockerade filändelser avvisas) och hantera deras länkar', - 'admin.plugins.perm.db:write:collab': 'Publicera anteckningar, omröstningar och chattmeddelanden på resor som den aktiva användaren kan redigera (kräver tillägget Collab)', - 'admin.plugins.perm.db:write:members': 'Lägga till användare i resor — ger resåtkomst; skyddas av den aktiva användarens medlemshanteringsrätt', - 'admin.plugins.perm.notify:send': 'Skicka en avisering (klockinkorg + e-post/ntfy) till den aktiva användaren eller en resa hen tillhör — aldrig till en godtycklig mottagare', - 'admin.plugins.perm.ai:invoke': 'Använda den administratörskonfigurerade AI-leverantören (textkomplettering + dokumentextraktion) å den aktiva användarens vägnar — tillägget håller aldrig en nyckel', - 'admin.plugins.perm.oauth:client': 'Ansluta till en tredjepartstjänst för din räkning via värdförmedlad OAuth (värden håller token, tillägget ser dem aldrig)', + 'admin.plugins.perm.db:write:todos': + 'Skapa, redigera och ta bort uppgifter på resor som den aktiva användaren kan redigera', + 'admin.plugins.perm.db:write:atlas': + 'Markera länder och regioner som besökta och hantera den aktiva användarens bucketlista (kräver tillägget Atlas)', + 'admin.plugins.perm.db:write:vacay': + 'Växla semesterdagar och företagshelgdagar i den aktiva användarens aktiva plan (kräver tillägget Vacay)', + 'admin.plugins.perm.db:write:journal': + 'Skapa, redigera och ta bort dagboksinlägg i dagböcker som den aktiva användaren kan redigera (kräver tillägget Journey)', + 'admin.plugins.perm.db:write:collections': + 'Skapa och redigera samlingar och spara platser i dem, med den aktiva användarens samlingsroll (kräver tillägget Collections)', + 'admin.plugins.perm.db:write:files': + 'Bifoga filer till resor som den aktiva användaren kan redigera (10 MB-gräns, blockerade filändelser avvisas) och hantera deras länkar', + 'admin.plugins.perm.db:write:collab': + 'Publicera anteckningar, omröstningar och chattmeddelanden på resor som den aktiva användaren kan redigera (kräver tillägget Collab)', + 'admin.plugins.perm.db:write:members': + 'Lägga till användare i resor — ger resåtkomst; skyddas av den aktiva användarens medlemshanteringsrätt', + 'admin.plugins.perm.notify:send': + 'Skicka en avisering (klockinkorg + e-post/ntfy) till den aktiva användaren eller en resa hen tillhör — aldrig till en godtycklig mottagare', + 'admin.plugins.perm.ai:invoke': + 'Använda den administratörskonfigurerade AI-leverantören (textkomplettering + dokumentextraktion) å den aktiva användarens vägnar — tillägget håller aldrig en nyckel', + 'admin.plugins.perm.oauth:client': + 'Ansluta till en tredjepartstjänst för din räkning via värdförmedlad OAuth (värden håller token, tillägget ser dem aldrig)', 'admin.plugins.perm.db:read:files': 'Läsa filer för resor som den aktiva användaren har åtkomst till', - 'admin.plugins.perm.db:write:reservations': 'Skapa, redigera och ta bort bokningar på resor som den aktiva användaren kan redigera', - 'admin.plugins.perm.db:write:accommodations': 'Skapa, redigera och ta bort boenden på resor som den aktiva användaren kan redigera', + 'admin.plugins.perm.db:write:reservations': + 'Skapa, redigera och ta bort bokningar på resor som den aktiva användaren kan redigera', + 'admin.plugins.perm.db:write:accommodations': + 'Skapa, redigera och ta bort boenden på resor som den aktiva användaren kan redigera', 'admin.plugins.perm.db:read:journal': 'Läsa den aktiva användarens resedagböcker (kräver tillägget Journey)', - 'admin.plugins.perm.db:read:atlas': 'Läsa den aktiva användarens besökta länder och regioner (kräver tillägget Atlas)', + 'admin.plugins.perm.db:read:atlas': + 'Läsa den aktiva användarens besökta länder och regioner (kräver tillägget Atlas)', 'admin.plugins.perm.db:read:vacay': 'Läsa den aktiva användarens semesterplan (kräver tillägget Vacay)', 'admin.plugins.perm.db:read:daynotes': 'Läsa daganteckningar för resor som den aktiva användaren har åtkomst till', - 'admin.plugins.perm.db:read:collections': 'Läsa den aktiva användarens samlingar med sparade platser (kräver tillägget Collections)', - 'admin.plugins.perm.db:write:daynotes': 'Skapa, redigera och ta bort daganteckningar på resor som den aktiva användaren kan redigera', - 'admin.plugins.perm.jobs:run': 'Köra sina deklarerade bakgrundsjobb enligt ett schema (utan användarkontext — kan inte läsa användardata)', + 'admin.plugins.perm.db:read:collections': + 'Läsa den aktiva användarens samlingar med sparade platser (kräver tillägget Collections)', + 'admin.plugins.perm.db:write:daynotes': + 'Skapa, redigera och ta bort daganteckningar på resor som den aktiva användaren kan redigera', + 'admin.plugins.perm.jobs:run': + 'Köra sina deklarerade bakgrundsjobb enligt ett schema (utan användarkontext — kan inte läsa användardata)', 'admin.plugins.perm.db:write:costs': 'Skapa kostnader (budgetposter) på resor som den aktiva användaren kan redigera', 'admin.plugins.perm.db:write:places': 'Lägga till, redigera och ta bort platser på resor som den aktiva användaren kan redigera', @@ -338,19 +366,25 @@ const admin: TranslationStrings = { 'admin.plugins.perm.hook:place-detail-provider': 'Bidra med extra detaljer (recensioner, betyg, länkar) till en plats', 'admin.plugins.perm.hook:trip-warning-provider': 'Visa valideringsvarningar på en resa (visas i planeraren)', - 'admin.plugins.perm.hook:table-contributor': 'Lägga till kolumner och åtgärder i rese­vyer (bokningar, platser, dagar)', + 'admin.plugins.perm.hook:table-contributor': + 'Lägga till kolumner och åtgärder i rese­vyer (bokningar, platser, dagar)', 'admin.plugins.perm.hook:map-marker-provider': 'Lägga till markörer på reskartan (t.ex. visa bokningar eller POI:er)', 'admin.plugins.perm.hook:pdf-section-provider': 'Lägga till textavsnitt i resans PDF-export', 'admin.plugins.perm.hook:atlas-layer-provider': 'Markera länder på Atlas-världskartan (t.ex. önskelistor eller resevarningar)', 'admin.plugins.perm.hook:journal-entry-provider': 'Lägga till extra rader (länkar, statistik) i dagboksinlägg', - 'admin.plugins.perm.hook:user-data': 'Radera eller exportera data den lagrar om en användare (GDPR: kontoborttagning och dataförfrågningar)', - 'admin.plugins.perm.hook:trip-card-provider': 'Lägga till små märken (status, antal) på resekorten på instrumentpanelen', + 'admin.plugins.perm.hook:user-data': + 'Radera eller exportera data den lagrar om en användare (GDPR: kontoborttagning och dataförfrågningar)', + 'admin.plugins.perm.hook:trip-card-provider': + 'Lägga till små märken (status, antal) på resekorten på instrumentpanelen', + 'admin.plugins.perm.hook:notification-channel': 'Leverera dina aviseringar via en ytterligare kanal', 'admin.plugins.perm.events:subscribe': 'Reagera på grundläggande aktivitetshändelser (endast händelsenamn + resa, aldrig innehållet)', 'admin.plugins.perm.http:outbound': 'Göra utgående anrop till sina deklarerade värdar', - 'admin.plugins.perm.db:read:collab': 'Läsa anteckningar, omröstningar och chattmeddelanden för resor som den aktiva användaren har åtkomst till (kräver tillägget Collab)', - 'admin.plugins.perm.db:read:files:content': 'Läsa innehållet (byte) i filer för resor som den aktiva användaren har åtkomst till', + 'admin.plugins.perm.db:read:collab': + 'Läsa anteckningar, omröstningar och chattmeddelanden för resor som den aktiva användaren har åtkomst till (kräver tillägget Collab)', + 'admin.plugins.perm.db:read:files:content': + 'Läsa innehållet (byte) i filer för resor som den aktiva användaren har åtkomst till', 'admin.plugins.perm.db:create:trips': 'Skapa nya resor som ägs av den aktiva användaren', 'admin.plugins.perm.rates:read': 'Läsa värdens cachade växelkurser', 'admin.plugins.updateConsentTitle': 'Den här uppdateringen kräver nya behörigheter', @@ -421,6 +455,7 @@ const admin: TranslationStrings = { 'admin.plugins.cap.reservationSlot': 'Bokningsdetalj', 'admin.plugins.cap.replacesTabs': 'Ersätter planeringsflikar', 'admin.plugins.cap.realtime': 'Realtidsuppdateringar', + 'admin.plugins.cap.notificationChannel': 'Aviseringskanal', 'admin.plugins.cap.photos': 'Tillhandahåller foton', 'admin.plugins.cap.calendar': 'Tillhandahåller kalenderhändelser', 'admin.plugins.cap.placeDetails': 'Berikar platser', diff --git a/shared/src/i18n/sv/settings.ts b/shared/src/i18n/sv/settings.ts index 30de4d174..216190130 100644 --- a/shared/src/i18n/sv/settings.ts +++ b/shared/src/i18n/sv/settings.ts @@ -95,6 +95,14 @@ const settings: TranslationStrings = { 'settings.notificationPreferences.email': 'E-post', 'settings.notificationPreferences.webhook': 'Webhook', 'settings.notificationPreferences.inapp': 'I appen', + 'settings.notificationPreferences.notConfigured': 'Inte konfigurerat än — ställ in det i pluginets inställningar', + 'settings.plugins.actions': 'Åtgärder', + 'settings.plugins.actions.confirm': 'Kör den här åtgärden?', + 'settings.notificationPreferences.sendTest': 'Skicka test', + 'settings.notificationPreferences.configure': 'Konfigurera', + 'settings.notificationPreferences.testSuccess': 'Testavisering skickad.', + 'settings.notificationPreferences.testFailed': 'Testet misslyckades.', + 'settings.notificationPreferences.pluginConfigured': 'Konfigurerat. Hantera dina uppgifter på pluginets inställningssida.', 'settings.notificationPreferences.ntfy': 'Ntfy', 'settings.notificationPreferences.noChannels': 'Inga meddelandekanaler har konfigurerats. Be en administratör att ställa in e-post- eller webhook-meddelanden.', diff --git a/shared/src/i18n/tr/admin.ts b/shared/src/i18n/tr/admin.ts index 33576ccb1..2548d9c88 100644 --- a/shared/src/i18n/tr/admin.ts +++ b/shared/src/i18n/tr/admin.ts @@ -213,7 +213,8 @@ const admin: TranslationStrings = { 'admin.plugins.uploaded': '“{name}” eklentisi yüklendi — çalıştırmak için etkinleştirin', 'admin.plugins.sideloaded': 'Elle yüklendi', 'admin.plugins.devLinkBadge': 'Dev-Link', - 'admin.plugins.devLinkHint': 'Yerel bir derleme dizininden yüklenir ve gerçek verilerle anında yeniden yüklenir — yalnızca geliştirme', + 'admin.plugins.devLinkHint': + 'Yerel bir derleme dizininden yüklenir ve gerçek verilerle anında yeniden yüklenir — yalnızca geliştirme', 'admin.plugins.devLinkTitle': 'Yerel bir eklenti bağla', 'admin.plugins.devLinkPathPlaceholder': '/absolute/path/to/your/plugin', 'admin.plugins.devLinkButton': 'Bağla', @@ -230,6 +231,15 @@ const admin: TranslationStrings = { 'admin.plugins.deactivated': 'Eklenti devre dışı bırakıldı', 'admin.plugins.viewErrors': 'Hata günlüğünü görüntüle', 'admin.plugins.errorLog': 'Hata günlüğü', + 'admin.plugins.allowedHosts': 'İzin verilen ana bilgisayarlar', + 'admin.plugins.allowedHosts.hint': 'Bu eklenti yalnızca sizin adlandırabileceğiniz bir hizmete bağlanır (kendi barındırdığınız sunucu). Erişebileceği ana bilgisayarları ekleyin — başkasına erişemez.', + 'admin.plugins.allowedHosts.none': 'Henüz ana bilgisayar eklenmedi.', + 'admin.plugins.allowedHosts.unsupported': 'Bu eklenti operatör tarafından verilen ana bilgisayarları kullanmaz. İzin verilenler manifestinde sabittir.', + 'admin.plugins.allowedHosts.restartNote': 'Kaydetmek, yeni listeyi alması için eklentiyi yeniden başlatır.', + 'admin.plugins.allowedHosts.add': 'İzin verilen ana bilgisayar ekle', + 'admin.plugins.allowedHosts.count': '{n} izin verilen ana bilgisayar', + 'admin.plugins.operatorEgressPill': '+ eklediğiniz ana bilgisayarlar', + 'admin.plugins.operatorEgressHint': 'Bu eklenti yalnızca sizin adlandırabileceğiniz bir hizmete bağlanır (kendi barındırdığınız sunucu). Kurulumdan sonra ⋯ → İzin verilen ana bilgisayarlar altından ekleyin. Başkasına erişemez.', 'admin.plugins.noErrors': 'Kayıtlı hata yok.', 'admin.plugins.uninstalled': 'Eklenti kaldırıldı', 'admin.plugins.uninstallTitle': 'Eklenti kaldırılsın mı?', @@ -280,33 +290,52 @@ const admin: TranslationStrings = { 'admin.plugins.perm.db:read:users': 'Temel profil bilgilerini okur (ad, avatar — asla kimlik bilgileri değil)', 'admin.plugins.perm.db:read:costs': 'İşlemi yapan kullanıcının erişebildiği harcamaları (bütçe kalemleri) okur', 'admin.plugins.perm.db:read:packing': 'İşlemi yapan kullanıcının erişebildiği seyahatlerin eşya listelerini okur', - 'admin.plugins.perm.db:write:packing': 'İşlemi yapan kullanıcının düzenleyebildiği seyahatlerde bavul listesi öğeleri oluşturur, düzenler ve siler', + 'admin.plugins.perm.db:write:packing': + 'İşlemi yapan kullanıcının düzenleyebildiği seyahatlerde bavul listesi öğeleri oluşturur, düzenler ve siler', 'admin.plugins.perm.weather:read': 'Ana makinenin önbelleğe alınmış hava durumu tahminini okur (koordinatlara göre)', 'admin.plugins.perm.db:read:categories': 'Genel yer kategorisi listesini okur', 'admin.plugins.perm.db:read:tags': 'İşlemi yapan kullanıcının etiketlerini okur', 'admin.plugins.perm.db:write:tags': 'İşlemi yapan kullanıcının etiketlerini oluşturur, düzenler ve siler', 'admin.plugins.perm.db:read:todos': 'İşlemi yapan kullanıcının erişebildiği seyahatlerin yapılacaklarını okur', - 'admin.plugins.perm.db:write:todos': 'İşlemi yapan kullanıcının düzenleyebildiği seyahatlerde yapılacaklar oluşturur, düzenler ve siler', - 'admin.plugins.perm.db:write:atlas': 'Ülkeleri ve bölgeleri ziyaret edildi olarak işaretler ve işlemi yapan kullanıcının hedef listesini yönetir (Atlas eklentisi gerektirir)', - 'admin.plugins.perm.db:write:vacay': 'İşlemi yapan kullanıcının etkin planında izin günlerini ve şirket tatillerini değiştirir (Vacay eklentisi gerektirir)', - 'admin.plugins.perm.db:write:journal': 'İşlemi yapan kullanıcının düzenleyebildiği günlüklerde girişler oluşturur, düzenler ve siler (Journey eklentisi gerektirir)', - 'admin.plugins.perm.db:write:collections': 'Koleksiyonlar oluşturur ve düzenler, işlemi yapan kullanıcının koleksiyon rolüyle içine yerler kaydeder (Collections eklentisi gerektirir)', - 'admin.plugins.perm.db:write:files': 'İşlemi yapan kullanıcının düzenleyebildiği seyahatlere dosyalar ekler (10 MB sınırı, engellenen uzantılar reddedilir) ve bağlantılarını yönetir', - 'admin.plugins.perm.db:write:collab': 'İşlemi yapan kullanıcının düzenleyebildiği seyahatlerde notlar, anketler ve sohbet mesajları yayınlar (Collab eklentisi gerektirir)', - 'admin.plugins.perm.db:write:members': 'Seyahatlere kullanıcı ekler — seyahat erişimi verir; işlemi yapan kullanıcının üye yönetimi hakkıyla korunur', - 'admin.plugins.perm.notify:send': 'İşlemi yapan kullanıcıya veya ait olduğu bir seyahate bildirim (zil kutusu + e-posta/ntfy) gönderir — asla rastgele bir alıcıya değil', - 'admin.plugins.perm.ai:invoke': 'Yönetici tarafından yapılandırılan yapay zekâ sağlayıcısını (metin tamamlama + belge çıkarımı) işlemi yapan kullanıcı adına çalıştırır — eklenti asla anahtar tutmaz', - 'admin.plugins.perm.oauth:client': 'Ana makine aracılı OAuth ile senin adına üçüncü taraf bir hizmete bağlanır (jetonları ana makine tutar, eklenti bunları hiç görmez)', + 'admin.plugins.perm.db:write:todos': + 'İşlemi yapan kullanıcının düzenleyebildiği seyahatlerde yapılacaklar oluşturur, düzenler ve siler', + 'admin.plugins.perm.db:write:atlas': + 'Ülkeleri ve bölgeleri ziyaret edildi olarak işaretler ve işlemi yapan kullanıcının hedef listesini yönetir (Atlas eklentisi gerektirir)', + 'admin.plugins.perm.db:write:vacay': + 'İşlemi yapan kullanıcının etkin planında izin günlerini ve şirket tatillerini değiştirir (Vacay eklentisi gerektirir)', + 'admin.plugins.perm.db:write:journal': + 'İşlemi yapan kullanıcının düzenleyebildiği günlüklerde girişler oluşturur, düzenler ve siler (Journey eklentisi gerektirir)', + 'admin.plugins.perm.db:write:collections': + 'Koleksiyonlar oluşturur ve düzenler, işlemi yapan kullanıcının koleksiyon rolüyle içine yerler kaydeder (Collections eklentisi gerektirir)', + 'admin.plugins.perm.db:write:files': + 'İşlemi yapan kullanıcının düzenleyebildiği seyahatlere dosyalar ekler (10 MB sınırı, engellenen uzantılar reddedilir) ve bağlantılarını yönetir', + 'admin.plugins.perm.db:write:collab': + 'İşlemi yapan kullanıcının düzenleyebildiği seyahatlerde notlar, anketler ve sohbet mesajları yayınlar (Collab eklentisi gerektirir)', + 'admin.plugins.perm.db:write:members': + 'Seyahatlere kullanıcı ekler — seyahat erişimi verir; işlemi yapan kullanıcının üye yönetimi hakkıyla korunur', + 'admin.plugins.perm.notify:send': + 'İşlemi yapan kullanıcıya veya ait olduğu bir seyahate bildirim (zil kutusu + e-posta/ntfy) gönderir — asla rastgele bir alıcıya değil', + 'admin.plugins.perm.ai:invoke': + 'Yönetici tarafından yapılandırılan yapay zekâ sağlayıcısını (metin tamamlama + belge çıkarımı) işlemi yapan kullanıcı adına çalıştırır — eklenti asla anahtar tutmaz', + 'admin.plugins.perm.oauth:client': + 'Ana makine aracılı OAuth ile senin adına üçüncü taraf bir hizmete bağlanır (jetonları ana makine tutar, eklenti bunları hiç görmez)', 'admin.plugins.perm.db:read:files': 'İşlemi yapan kullanıcının erişebildiği seyahatlerin dosyalarını okur', - 'admin.plugins.perm.db:write:reservations': 'İşlemi yapan kullanıcının düzenleyebildiği seyahatlerde rezervasyonlar oluşturur, düzenler ve siler', - 'admin.plugins.perm.db:write:accommodations': 'İşlemi yapan kullanıcının düzenleyebildiği seyahatlerde konaklamalar oluşturur, düzenler ve siler', - 'admin.plugins.perm.db:read:journal': 'İşlemi yapan kullanıcının seyahat günlüklerini okur (Journey eklentisi gerektirir)', - 'admin.plugins.perm.db:read:atlas': 'İşlemi yapan kullanıcının ziyaret ettiği ülkeleri ve bölgeleri okur (Atlas eklentisi gerektirir)', + 'admin.plugins.perm.db:write:reservations': + 'İşlemi yapan kullanıcının düzenleyebildiği seyahatlerde rezervasyonlar oluşturur, düzenler ve siler', + 'admin.plugins.perm.db:write:accommodations': + 'İşlemi yapan kullanıcının düzenleyebildiği seyahatlerde konaklamalar oluşturur, düzenler ve siler', + 'admin.plugins.perm.db:read:journal': + 'İşlemi yapan kullanıcının seyahat günlüklerini okur (Journey eklentisi gerektirir)', + 'admin.plugins.perm.db:read:atlas': + 'İşlemi yapan kullanıcının ziyaret ettiği ülkeleri ve bölgeleri okur (Atlas eklentisi gerektirir)', 'admin.plugins.perm.db:read:vacay': 'İşlemi yapan kullanıcının tatil planını okur (Vacay eklentisi gerektirir)', 'admin.plugins.perm.db:read:daynotes': 'İşlemi yapan kullanıcının erişebildiği seyahatlerin gün notlarını okur', - 'admin.plugins.perm.db:read:collections': 'İşlemi yapan kullanıcının kaydedilen yer koleksiyonlarını okur (Collections eklentisi gerektirir)', - 'admin.plugins.perm.db:write:daynotes': 'İşlemi yapan kullanıcının düzenleyebildiği seyahatlerde gün notları oluşturur, düzenler ve siler', - 'admin.plugins.perm.jobs:run': 'Bildirdiği arka plan işlerini bir zamanlamaya göre çalıştırır (kullanıcı bağlamı yok — kullanıcı verilerini okuyamaz)', + 'admin.plugins.perm.db:read:collections': + 'İşlemi yapan kullanıcının kaydedilen yer koleksiyonlarını okur (Collections eklentisi gerektirir)', + 'admin.plugins.perm.db:write:daynotes': + 'İşlemi yapan kullanıcının düzenleyebildiği seyahatlerde gün notları oluşturur, düzenler ve siler', + 'admin.plugins.perm.jobs:run': + 'Bildirdiği arka plan işlerini bir zamanlamaya göre çalıştırır (kullanıcı bağlamı yok — kullanıcı verilerini okuyamaz)', 'admin.plugins.perm.db:write:costs': 'İşlemi yapan kullanıcının düzenleyebildiği seyahatlerde harcamalar (bütçe kalemleri) oluşturur', 'admin.plugins.perm.db:write:places': @@ -326,19 +355,26 @@ const admin: TranslationStrings = { 'admin.plugins.perm.hook:place-detail-provider': 'Bir yere ek ayrıntılar (yorumlar, puanlar, bağlantılar) katkıda bulunur', 'admin.plugins.perm.hook:trip-warning-provider': 'Bir gezide doğrulama uyarıları oluşturur (planlayıcıda gösterilir)', - 'admin.plugins.perm.hook:table-contributor': 'Seyahat görünümlerine sütunlar ve eylemler ekler (rezervasyonlar, yerler, günler)', - 'admin.plugins.perm.hook:map-marker-provider': "Seyahat haritasına işaretçiler ekler (ör. rezervasyonları veya POI'leri gösterir)", + 'admin.plugins.perm.hook:table-contributor': + 'Seyahat görünümlerine sütunlar ve eylemler ekler (rezervasyonlar, yerler, günler)', + 'admin.plugins.perm.hook:map-marker-provider': + "Seyahat haritasına işaretçiler ekler (ör. rezervasyonları veya POI'leri gösterir)", 'admin.plugins.perm.hook:pdf-section-provider': 'Seyahat PDF dışa aktarımına metin bölümleri ekler', 'admin.plugins.perm.hook:atlas-layer-provider': 'Atlas dünya haritasında ülkeleri vurgular (ör. istek listeleri veya seyahat uyarıları)', 'admin.plugins.perm.hook:journal-entry-provider': 'Günlük girişlerine ek satırlar (bağlantılar, istatistikler) ekler', - 'admin.plugins.perm.hook:user-data': 'Bir kullanıcı hakkında sakladığı verileri silme veya dışa aktarma (KVKK/GDPR: hesap silme ve veri talepleri)', - 'admin.plugins.perm.hook:trip-card-provider': 'Kontrol panelindeki gezi kartlarına küçük rozetler (durum, sayaçlar) ekleme', + 'admin.plugins.perm.hook:user-data': + 'Bir kullanıcı hakkında sakladığı verileri silme veya dışa aktarma (KVKK/GDPR: hesap silme ve veri talepleri)', + 'admin.plugins.perm.hook:trip-card-provider': + 'Kontrol panelindeki gezi kartlarına küçük rozetler (durum, sayaçlar) ekleme', + 'admin.plugins.perm.hook:notification-channel': 'Bildirimlerinizi ek bir kanal üzerinden iletmek', 'admin.plugins.perm.events:subscribe': 'Temel etkinlik olaylarına tepki verir (yalnızca olay adı + seyahat, asla içerik değil)', 'admin.plugins.perm.http:outbound': 'Bildirdiği ana bilgisayarlara giden istekler yapar', - 'admin.plugins.perm.db:read:collab': 'İşlemi yapan kullanıcının erişebildiği seyahatlerin notlarını, anketlerini ve sohbet mesajlarını okur (Collab eklentisi gerektirir)', - 'admin.plugins.perm.db:read:files:content': 'İşlemi yapan kullanıcının erişebildiği seyahatlerdeki dosyaların içeriğini (baytlar) okur', + 'admin.plugins.perm.db:read:collab': + 'İşlemi yapan kullanıcının erişebildiği seyahatlerin notlarını, anketlerini ve sohbet mesajlarını okur (Collab eklentisi gerektirir)', + 'admin.plugins.perm.db:read:files:content': + 'İşlemi yapan kullanıcının erişebildiği seyahatlerdeki dosyaların içeriğini (baytlar) okur', 'admin.plugins.perm.db:create:trips': 'İşlemi yapan kullanıcıya ait yeni seyahatler oluşturur', 'admin.plugins.perm.rates:read': 'Ana makinenin önbelleğe alınmış döviz kurlarını okur', 'admin.plugins.updateConsentTitle': 'Bu güncelleme yeni izinler gerektiriyor', @@ -408,6 +444,7 @@ const admin: TranslationStrings = { 'admin.plugins.cap.reservationSlot': 'Rezervasyon detayı', 'admin.plugins.cap.replacesTabs': 'Planlayıcı sekmelerini değiştirir', 'admin.plugins.cap.realtime': 'Gerçek zamanlı güncellemeler', + 'admin.plugins.cap.notificationChannel': 'Bildirim kanalı', 'admin.plugins.cap.photos': 'Fotoğraf sağlar', 'admin.plugins.cap.calendar': 'Takvim etkinlikleri sağlar', 'admin.plugins.cap.placeDetails': 'Yerleri zenginleştirir', diff --git a/shared/src/i18n/tr/settings.ts b/shared/src/i18n/tr/settings.ts index 23684bcc0..4044bfd95 100644 --- a/shared/src/i18n/tr/settings.ts +++ b/shared/src/i18n/tr/settings.ts @@ -90,6 +90,14 @@ const settings: TranslationStrings = { 'settings.notificationPreferences.email': 'E-posta', '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.plugins.actions': 'Eylemler', + 'settings.plugins.actions.confirm': 'Bu eylem çalıştırılsın mı?', + 'settings.notificationPreferences.sendTest': 'Test gönder', + 'settings.notificationPreferences.configure': 'Yapılandır', + 'settings.notificationPreferences.testSuccess': 'Test bildirimi gönderildi.', + 'settings.notificationPreferences.testFailed': 'Test başarısız.', + 'settings.notificationPreferences.pluginConfigured': 'Yapılandırıldı. Kimlik bilgilerini eklentinin ayarlar sayfasından yönetin.', 'settings.notificationPreferences.ntfy': 'Ntfy', 'settings.notificationPreferences.noChannels': 'Bildirim kanalı yapılandırılmadı. Yöneticiden e-posta veya webhook kurmasını isteyin.', diff --git a/shared/src/i18n/uk/admin.ts b/shared/src/i18n/uk/admin.ts index becbec421..10a11233f 100644 --- a/shared/src/i18n/uk/admin.ts +++ b/shared/src/i18n/uk/admin.ts @@ -169,7 +169,8 @@ const admin: TranslationStrings = { 'admin.plugins.uploaded': 'Плагін “{name}” завантажено — активуйте для запуску', 'admin.plugins.sideloaded': 'Завантажено вручну', 'admin.plugins.devLinkBadge': 'Dev-Link', - 'admin.plugins.devLinkHint': 'Завантажено з локального каталогу збірки та гаряче перезавантажується з реальними даними — лише для розробки', + 'admin.plugins.devLinkHint': + 'Завантажено з локального каталогу збірки та гаряче перезавантажується з реальними даними — лише для розробки', 'admin.plugins.devLinkTitle': 'Підключити локальний плагін', 'admin.plugins.devLinkPathPlaceholder': '/absolute/path/to/your/plugin', 'admin.plugins.devLinkButton': 'Підключити', @@ -186,6 +187,15 @@ const admin: TranslationStrings = { 'admin.plugins.deactivated': 'Плагін деактивовано', 'admin.plugins.viewErrors': 'Переглянути журнал помилок', 'admin.plugins.errorLog': 'Журнал помилок', + 'admin.plugins.allowedHosts': 'Дозволені хости', + 'admin.plugins.allowedHosts.hint': 'Цей плагін звертається до сервісу, який можете вказати лише ви (self-hosted сервер). Додайте хости, до яких він може звертатися — до інших він не зможе.', + 'admin.plugins.allowedHosts.none': 'Хости ще не додано.', + 'admin.plugins.allowedHosts.unsupported': 'Цей плагін не використовує хости, задані оператором. Його дозволені хости зафіксовані в маніфесті.', + 'admin.plugins.allowedHosts.restartNote': 'Збереження перезапускає плагін, щоб він підхопив новий список.', + 'admin.plugins.allowedHosts.add': 'Додати дозволений хост', + 'admin.plugins.allowedHosts.count': 'Дозволених хостів: {n}', + 'admin.plugins.operatorEgressPill': '+ додані вами хости', + 'admin.plugins.operatorEgressHint': 'Цей плагін звертається до сервісу, який можете вказати лише ви (self-hosted сервер). Після встановлення додайте хости в ⋯ → Дозволені хости. До інших він не зможе звертатися.', 'admin.plugins.noErrors': 'Помилок не зафіксовано.', 'admin.plugins.uninstalled': 'Плагін видалено', 'admin.plugins.uninstallTitle': 'Видалити плагін?', @@ -235,33 +245,51 @@ const admin: TranslationStrings = { 'admin.plugins.perm.db:read:users': "Читати базову інформацію профілю (ім'я, аватар — ніколи не облікові дані)", 'admin.plugins.perm.db:read:costs': 'Читати витрати (статті бюджету), до яких має доступ поточний користувач', 'admin.plugins.perm.db:read:packing': 'Читати списки речей подорожей, до яких має доступ поточний користувач', - 'admin.plugins.perm.db:write:packing': 'Створювати, редагувати та видаляти пункти списку речей у подорожах, які може редагувати поточний користувач', + 'admin.plugins.perm.db:write:packing': + 'Створювати, редагувати та видаляти пункти списку речей у подорожах, які може редагувати поточний користувач', 'admin.plugins.perm.weather:read': 'Читати кешований прогноз погоди хоста (за координатами)', 'admin.plugins.perm.db:read:categories': 'Читати глобальний список категорій місць', 'admin.plugins.perm.db:read:tags': 'Читати теги поточного користувача', 'admin.plugins.perm.db:write:tags': 'Створювати, редагувати та видаляти теги поточного користувача', 'admin.plugins.perm.db:read:todos': 'Читати завдання подорожей, до яких має доступ поточний користувач', - 'admin.plugins.perm.db:write:todos': 'Створювати, редагувати та видаляти завдання в подорожах, які може редагувати поточний користувач', - 'admin.plugins.perm.db:write:atlas': 'Позначати країни та регіони як відвідані та керувати списком бажань поточного користувача (потрібен додаток Atlas)', - 'admin.plugins.perm.db:write:vacay': 'Перемикати дні відпустки та корпоративні свята в активному плані поточного користувача (потрібен додаток Vacay)', - 'admin.plugins.perm.db:write:journal': 'Створювати, редагувати та видаляти записи в щоденниках, які може редагувати поточний користувач (потрібен додаток Journey)', - 'admin.plugins.perm.db:write:collections': 'Створювати та редагувати колекції й зберігати в них місця з роллю поточного користувача в колекції (потрібен додаток Collections)', - 'admin.plugins.perm.db:write:files': "Прикріплювати файли до подорожей, які може редагувати поточний користувач (ліміт 10 МБ, заблоковані розширення відхиляються), і керувати їхніми зв'язками", - 'admin.plugins.perm.db:write:collab': 'Публікувати нотатки, опитування та повідомлення чату в подорожах, які може редагувати поточний користувач (потрібен додаток Collab)', - 'admin.plugins.perm.db:write:members': 'Додавати користувачів до подорожей — надає доступ до подорожі; захищено правом керування учасниками поточного користувача', - 'admin.plugins.perm.notify:send': 'Надсилати сповіщення (скринька дзвіночка + email/ntfy) поточному користувачеві або його подорожі — ніколи довільному одержувачу', - 'admin.plugins.perm.ai:invoke': 'Використовувати налаштованого адміністратором ШІ-провайдера (доповнення тексту + видобування з документів) від імені поточного користувача — плагін ніколи не зберігає ключ', - 'admin.plugins.perm.oauth:client': 'Підключення до сторонньої служби від вашого імені через OAuth за посередництва хоста (хост зберігає токени, плагін їх не бачить)', + 'admin.plugins.perm.db:write:todos': + 'Створювати, редагувати та видаляти завдання в подорожах, які може редагувати поточний користувач', + 'admin.plugins.perm.db:write:atlas': + 'Позначати країни та регіони як відвідані та керувати списком бажань поточного користувача (потрібен додаток Atlas)', + 'admin.plugins.perm.db:write:vacay': + 'Перемикати дні відпустки та корпоративні свята в активному плані поточного користувача (потрібен додаток Vacay)', + 'admin.plugins.perm.db:write:journal': + 'Створювати, редагувати та видаляти записи в щоденниках, які може редагувати поточний користувач (потрібен додаток Journey)', + 'admin.plugins.perm.db:write:collections': + 'Створювати та редагувати колекції й зберігати в них місця з роллю поточного користувача в колекції (потрібен додаток Collections)', + 'admin.plugins.perm.db:write:files': + "Прикріплювати файли до подорожей, які може редагувати поточний користувач (ліміт 10 МБ, заблоковані розширення відхиляються), і керувати їхніми зв'язками", + 'admin.plugins.perm.db:write:collab': + 'Публікувати нотатки, опитування та повідомлення чату в подорожах, які може редагувати поточний користувач (потрібен додаток Collab)', + 'admin.plugins.perm.db:write:members': + 'Додавати користувачів до подорожей — надає доступ до подорожі; захищено правом керування учасниками поточного користувача', + 'admin.plugins.perm.notify:send': + 'Надсилати сповіщення (скринька дзвіночка + email/ntfy) поточному користувачеві або його подорожі — ніколи довільному одержувачу', + 'admin.plugins.perm.ai:invoke': + 'Використовувати налаштованого адміністратором ШІ-провайдера (доповнення тексту + видобування з документів) від імені поточного користувача — плагін ніколи не зберігає ключ', + 'admin.plugins.perm.oauth:client': + 'Підключення до сторонньої служби від вашого імені через OAuth за посередництва хоста (хост зберігає токени, плагін їх не бачить)', 'admin.plugins.perm.db:read:files': 'Читати файли подорожей, до яких має доступ поточний користувач', - 'admin.plugins.perm.db:write:reservations': 'Створювати, редагувати та видаляти бронювання в подорожах, які може редагувати поточний користувач', - 'admin.plugins.perm.db:write:accommodations': 'Створювати, редагувати та видаляти проживання в подорожах, які може редагувати поточний користувач', + 'admin.plugins.perm.db:write:reservations': + 'Створювати, редагувати та видаляти бронювання в подорожах, які може редагувати поточний користувач', + 'admin.plugins.perm.db:write:accommodations': + 'Створювати, редагувати та видаляти проживання в подорожах, які може редагувати поточний користувач', 'admin.plugins.perm.db:read:journal': 'Читати щоденники подорожей поточного користувача (потрібен додаток Journey)', - 'admin.plugins.perm.db:read:atlas': 'Читати відвідані країни та регіони поточного користувача (потрібен додаток Atlas)', + 'admin.plugins.perm.db:read:atlas': + 'Читати відвідані країни та регіони поточного користувача (потрібен додаток Atlas)', 'admin.plugins.perm.db:read:vacay': 'Читати план відпустки поточного користувача (потрібен додаток Vacay)', 'admin.plugins.perm.db:read:daynotes': 'Читати нотатки дня подорожей, до яких має доступ поточний користувач', - 'admin.plugins.perm.db:read:collections': 'Читати колекції збережених місць поточного користувача (потрібен додаток Collections)', - 'admin.plugins.perm.db:write:daynotes': 'Створювати, редагувати та видаляти нотатки дня в подорожах, які може редагувати поточний користувач', - 'admin.plugins.perm.jobs:run': 'Запускати оголошені фонові завдання за розкладом (без контексту користувача — не може читати дані користувача)', + 'admin.plugins.perm.db:read:collections': + 'Читати колекції збережених місць поточного користувача (потрібен додаток Collections)', + 'admin.plugins.perm.db:write:daynotes': + 'Створювати, редагувати та видаляти нотатки дня в подорожах, які може редагувати поточний користувач', + 'admin.plugins.perm.jobs:run': + 'Запускати оголошені фонові завдання за розкладом (без контексту користувача — не може читати дані користувача)', 'admin.plugins.perm.db:write:costs': 'Створювати витрати (статті бюджету) у подорожах, які може редагувати поточний користувач', 'admin.plugins.perm.db:write:places': @@ -282,19 +310,25 @@ const admin: TranslationStrings = { 'admin.plugins.perm.hook:trip-warning-provider': 'Створювати попередження перевірки для подорожі (показуються в планувальнику)', 'admin.plugins.perm.hook:table-contributor': 'Додавати стовпці та дії до подань подорожі (бронювання, місця, дні)', - 'admin.plugins.perm.hook:map-marker-provider': 'Додавати мітки на карту подорожі (наприклад, показувати бронювання або POI)', + 'admin.plugins.perm.hook:map-marker-provider': + 'Додавати мітки на карту подорожі (наприклад, показувати бронювання або POI)', 'admin.plugins.perm.hook:pdf-section-provider': 'Додавати текстові розділи до PDF-експорту подорожі', 'admin.plugins.perm.hook:atlas-layer-provider': 'Виділяти країни на карті світу в Атласі (наприклад, списки бажань або попередження для мандрівників)', 'admin.plugins.perm.hook:journal-entry-provider': 'Додавати додаткові рядки (посилання, статистику) до записів щоденника', - 'admin.plugins.perm.hook:user-data': 'Видалення або експорт збережених даних про користувача (GDPR: видалення акаунта та запити даних)', - 'admin.plugins.perm.hook:trip-card-provider': 'Додавати невеликі значки (статус, лічильники) на картки подорожей на панелі', + 'admin.plugins.perm.hook:user-data': + 'Видалення або експорт збережених даних про користувача (GDPR: видалення акаунта та запити даних)', + 'admin.plugins.perm.hook:trip-card-provider': + 'Додавати невеликі значки (статус, лічильники) на картки подорожей на панелі', + 'admin.plugins.perm.hook:notification-channel': 'Доставляти ваші сповіщення через додатковий канал', 'admin.plugins.perm.events:subscribe': 'Реагувати на основні події активності (лише назва події + подорож, ніколи вміст)', 'admin.plugins.perm.http:outbound': 'Виконувати вихідні запити до заявлених хостів', - 'admin.plugins.perm.db:read:collab': 'Читати нотатки, опитування та повідомлення чату подорожей, до яких має доступ поточний користувач (потрібен додаток Collab)', - 'admin.plugins.perm.db:read:files:content': 'Читати вміст (байти) файлів подорожей, до яких має доступ поточний користувач', + 'admin.plugins.perm.db:read:collab': + 'Читати нотатки, опитування та повідомлення чату подорожей, до яких має доступ поточний користувач (потрібен додаток Collab)', + 'admin.plugins.perm.db:read:files:content': + 'Читати вміст (байти) файлів подорожей, до яких має доступ поточний користувач', 'admin.plugins.perm.db:create:trips': 'Створювати нові подорожі, що належать поточному користувачеві', 'admin.plugins.perm.rates:read': 'Читати кешовані курси валют хоста', 'admin.plugins.updateConsentTitle': 'Це оновлення потребує нових дозволів', @@ -364,6 +398,7 @@ const admin: TranslationStrings = { 'admin.plugins.cap.reservationSlot': 'Деталі бронювання', 'admin.plugins.cap.replacesTabs': 'Замінює вкладки планувальника', 'admin.plugins.cap.realtime': 'Оновлення в реальному часі', + 'admin.plugins.cap.notificationChannel': 'Канал сповіщень', 'admin.plugins.cap.photos': 'Надає фотографії', 'admin.plugins.cap.calendar': 'Надає події календаря', 'admin.plugins.cap.placeDetails': 'Збагачує місця', diff --git a/shared/src/i18n/uk/settings.ts b/shared/src/i18n/uk/settings.ts index 865296670..8c583e8c7 100644 --- a/shared/src/i18n/uk/settings.ts +++ b/shared/src/i18n/uk/settings.ts @@ -13,7 +13,8 @@ const settings: TranslationStrings = { 'settings.plugins.oauth.disconnect': 'Відключити', 'settings.tabs.plugins': 'Плагіни', 'settings.plugins.title': 'Налаштування плагінів', - 'settings.plugins.subtitle': 'Ваші особисті налаштування для плагінів, які ви використовуєте (ключі API, уподобання).', + 'settings.plugins.subtitle': + 'Ваші особисті налаштування для плагінів, які ви використовуєте (ключі API, уподобання).', 'settings.plugins.empty': 'Немає активних плагінів.', 'settings.plugins.saved': 'Налаштування збережено', 'settings.tabs.account': 'Обліковий запис', @@ -282,6 +283,14 @@ const settings: TranslationStrings = { 'settings.ntfyUrl.testFailed': 'Помилка надсилання тестового сповіщення Ntfy', 'settings.ntfyUrl.tokenCleared': 'Токен доступу очищено', 'settings.notificationPreferences.inapp': 'In-App', + 'settings.notificationPreferences.notConfigured': 'Ще не налаштовано — налаштуйте в параметрах плагіна', + 'settings.plugins.actions': 'Дії', + 'settings.plugins.actions.confirm': 'Виконати цю дію?', + 'settings.notificationPreferences.sendTest': 'Надіслати тест', + 'settings.notificationPreferences.configure': 'Налаштувати', + 'settings.notificationPreferences.testSuccess': 'Тестове сповіщення надіслано.', + 'settings.notificationPreferences.testFailed': 'Тест не вдався.', + 'settings.notificationPreferences.pluginConfigured': 'Налаштовано. Облікові дані — на сторінці налаштувань плагіна.', 'settings.notificationPreferences.webhook': 'Webhook', 'settings.notificationPreferences.email': 'Email', 'settings.notificationPreferences.ntfy': 'Ntfy', diff --git a/shared/src/i18n/vi/admin.ts b/shared/src/i18n/vi/admin.ts index 265b6c297..5cae436c0 100644 --- a/shared/src/i18n/vi/admin.ts +++ b/shared/src/i18n/vi/admin.ts @@ -224,7 +224,8 @@ const admin: TranslationStrings = { 'admin.plugins.uploaded': 'Đã tải lên plugin “{name}” — kích hoạt để chạy', 'admin.plugins.sideloaded': 'Tải thủ công', 'admin.plugins.devLinkBadge': 'Dev-Link', - 'admin.plugins.devLinkHint': 'Được tải từ thư mục build cục bộ và tải lại nóng với dữ liệu thật — chỉ dành cho phát triển', + 'admin.plugins.devLinkHint': + 'Được tải từ thư mục build cục bộ và tải lại nóng với dữ liệu thật — chỉ dành cho phát triển', 'admin.plugins.devLinkTitle': 'Liên kết plugin cục bộ', 'admin.plugins.devLinkPathPlaceholder': '/absolute/path/to/your/plugin', 'admin.plugins.devLinkButton': 'Liên kết', @@ -241,6 +242,15 @@ const admin: TranslationStrings = { 'admin.plugins.deactivated': 'Đã hủy kích hoạt plugin', 'admin.plugins.viewErrors': 'Xem nhật ký lỗi', 'admin.plugins.errorLog': 'Nhật ký lỗi', + 'admin.plugins.allowedHosts': 'Máy chủ được phép', + 'admin.plugins.allowedHosts.hint': 'Plugin này kết nối tới một dịch vụ mà chỉ bạn mới biết tên (máy chủ tự lưu trữ). Hãy thêm các máy chủ mà nó được phép truy cập — ngoài ra không có gì khác.', + 'admin.plugins.allowedHosts.none': 'Chưa thêm máy chủ nào.', + 'admin.plugins.allowedHosts.unsupported': 'Plugin này không dùng máy chủ do người vận hành cung cấp. Danh sách được cố định trong manifest.', + 'admin.plugins.allowedHosts.restartNote': 'Lưu sẽ khởi động lại plugin để nó dùng danh sách mới.', + 'admin.plugins.allowedHosts.add': 'Thêm máy chủ được phép', + 'admin.plugins.allowedHosts.count': '{n} máy chủ được phép', + 'admin.plugins.operatorEgressPill': '+ máy chủ bạn thêm', + 'admin.plugins.operatorEgressHint': 'Plugin này kết nối tới một dịch vụ mà chỉ bạn mới biết tên (máy chủ tự lưu trữ). Sau khi cài, hãy thêm máy chủ tại ⋯ → Máy chủ được phép. Ngoài ra không có gì khác.', 'admin.plugins.noErrors': 'Chưa ghi nhận lỗi nào.', 'admin.plugins.uninstalled': 'Đã gỡ cài đặt plugin', 'admin.plugins.uninstallTitle': 'Gỡ cài đặt plugin?', @@ -288,7 +298,8 @@ const admin: TranslationStrings = { 'admin.plugins.reviewedOn': 'Đã kiểm duyệt {date}', 'admin.plugins.perm.db:own': 'Lưu trữ dữ liệu riêng trong một cơ sở dữ liệu cách ly', 'admin.plugins.perm.db:read:trips': 'Đọc các chuyến đi mà người dùng hiện tại có quyền truy cập', - 'admin.plugins.perm.db:read:users': 'Đọc thông tin hồ sơ cơ bản (tên, ảnh đại diện — không bao giờ là thông tin đăng nhập)', + 'admin.plugins.perm.db:read:users': + 'Đọc thông tin hồ sơ cơ bản (tên, ảnh đại diện — không bao giờ là thông tin đăng nhập)', 'admin.plugins.perm.ws:broadcast:trip': 'Gửi cập nhật thời gian thực đến các thành viên', 'admin.plugins.perm.ws:broadcast:user': 'Gửi cập nhật thời gian thực đến từng người dùng', 'admin.plugins.perm.hook:photo-provider': 'Cung cấp ảnh cho tính năng Ảnh', @@ -297,19 +308,26 @@ const admin: TranslationStrings = { 'Đóng góp thêm thông tin chi tiết (đánh giá, xếp hạng, liên kết) cho một địa điểm', 'admin.plugins.perm.hook:trip-warning-provider': 'Đưa ra cảnh báo xác thực trên một chuyến đi (hiển thị trong trình lập kế hoạch)', - 'admin.plugins.perm.hook:table-contributor': 'Thêm cột và hành động vào các chế độ xem chuyến đi (đặt chỗ, địa điểm, ngày)', - 'admin.plugins.perm.hook:map-marker-provider': 'Thêm điểm đánh dấu vào bản đồ chuyến đi (vd. hiển thị đặt chỗ hoặc POI)', + 'admin.plugins.perm.hook:table-contributor': + 'Thêm cột và hành động vào các chế độ xem chuyến đi (đặt chỗ, địa điểm, ngày)', + 'admin.plugins.perm.hook:map-marker-provider': + 'Thêm điểm đánh dấu vào bản đồ chuyến đi (vd. hiển thị đặt chỗ hoặc POI)', 'admin.plugins.perm.hook:pdf-section-provider': 'Thêm các phần văn bản vào bản xuất PDF của chuyến đi', 'admin.plugins.perm.hook:atlas-layer-provider': 'Làm nổi bật các quốc gia trên bản đồ thế giới Atlas (vd. danh sách mong muốn hoặc cảnh báo du lịch)', 'admin.plugins.perm.hook:journal-entry-provider': 'Thêm các dòng bổ sung (liên kết, thống kê) vào các mục nhật ký', - 'admin.plugins.perm.hook:user-data': 'Xóa hoặc xuất dữ liệu mà nó lưu về một người dùng (GDPR: xóa tài khoản và yêu cầu dữ liệu)', - 'admin.plugins.perm.hook:trip-card-provider': 'Thêm huy hiệu nhỏ (trạng thái, số đếm) vào thẻ chuyến đi trên bảng điều khiển', + 'admin.plugins.perm.hook:user-data': + 'Xóa hoặc xuất dữ liệu mà nó lưu về một người dùng (GDPR: xóa tài khoản và yêu cầu dữ liệu)', + 'admin.plugins.perm.hook:trip-card-provider': + 'Thêm huy hiệu nhỏ (trạng thái, số đếm) vào thẻ chuyến đi trên bảng điều khiển', + 'admin.plugins.perm.hook:notification-channel': 'Gửi thông báo của bạn qua một kênh bổ sung', 'admin.plugins.perm.events:subscribe': 'Phản hồi các sự kiện hoạt động cốt lõi (chỉ tên sự kiện + chuyến đi, không bao giờ là nội dung)', 'admin.plugins.perm.http:outbound': 'Gửi yêu cầu ra ngoài đến các máy chủ đã khai báo', - 'admin.plugins.perm.db:read:collab': 'Đọc ghi chú, bình chọn và tin nhắn trò chuyện của các chuyến đi mà người dùng hiện tại có quyền truy cập (cần add-on Collab)', - 'admin.plugins.perm.db:read:files:content': 'Đọc nội dung (byte) tệp của các chuyến đi mà người dùng hiện tại có quyền truy cập', + 'admin.plugins.perm.db:read:collab': + 'Đọc ghi chú, bình chọn và tin nhắn trò chuyện của các chuyến đi mà người dùng hiện tại có quyền truy cập (cần add-on Collab)', + 'admin.plugins.perm.db:read:files:content': + 'Đọc nội dung (byte) tệp của các chuyến đi mà người dùng hiện tại có quyền truy cập', 'admin.plugins.perm.db:create:trips': 'Tạo chuyến đi mới thuộc sở hữu của người dùng hiện tại', 'admin.plugins.perm.rates:read': 'Đọc tỷ giá hối đoái đã lưu cache của máy chủ', 'admin.plugins.updateConsentTitle': 'Bản cập nhật này cần các quyền mới', @@ -379,6 +397,7 @@ const admin: TranslationStrings = { 'admin.plugins.cap.reservationSlot': 'Chi tiết đặt chỗ', 'admin.plugins.cap.replacesTabs': 'Thay thế các tab trình lập kế hoạch', 'admin.plugins.cap.realtime': 'Cập nhật thời gian thực', + 'admin.plugins.cap.notificationChannel': 'Kênh thông báo', 'admin.plugins.cap.photos': 'Cung cấp ảnh', 'admin.plugins.cap.calendar': 'Cung cấp sự kiện lịch', 'admin.plugins.cap.placeDetails': 'Làm phong phú địa điểm', diff --git a/shared/src/i18n/vi/settings.ts b/shared/src/i18n/vi/settings.ts index dcb49d336..68674e6f0 100644 --- a/shared/src/i18n/vi/settings.ts +++ b/shared/src/i18n/vi/settings.ts @@ -96,6 +96,14 @@ const settings: TranslationStrings = { 'settings.notificationPreferences.email': 'Email', '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.plugins.actions': 'Hành động', + 'settings.plugins.actions.confirm': 'Chạy hành động này?', + 'settings.notificationPreferences.sendTest': 'Gửi thử', + 'settings.notificationPreferences.configure': 'Cấu hình', + 'settings.notificationPreferences.testSuccess': 'Đã gửi thông báo thử.', + 'settings.notificationPreferences.testFailed': 'Thử nghiệm thất bại.', + 'settings.notificationPreferences.pluginConfigured': 'Đã cấu hình. Quản lý thông tin đăng nhập ở trang cài đặt của plugin.', 'settings.notificationPreferences.ntfy': 'Ntfy', 'settings.notificationPreferences.noChannels': 'Không có kênh thông báo nào được cấu hình. Yêu cầu quản trị viên thiết lập thông báo qua email hoặc webhook.', diff --git a/shared/src/i18n/zh-TW/admin.ts b/shared/src/i18n/zh-TW/admin.ts index 3106cd8c0..571cfc76f 100644 --- a/shared/src/i18n/zh-TW/admin.ts +++ b/shared/src/i18n/zh-TW/admin.ts @@ -220,6 +220,15 @@ const admin: TranslationStrings = { 'admin.plugins.deactivated': '外掛已停用', 'admin.plugins.viewErrors': '檢視錯誤日誌', 'admin.plugins.errorLog': '錯誤日誌', + 'admin.plugins.allowedHosts': '允許的主機', + 'admin.plugins.allowedHosts.hint': '此外掛會連線到只有你才知道位址的服務(自架伺服器)。請新增它可以存取的主機 —— 其他主機一律無法存取。', + 'admin.plugins.allowedHosts.none': '尚未新增主機。', + 'admin.plugins.allowedHosts.unsupported': '此外掛不使用營運者提供的主機。其允許的主機已在資訊清單中固定。', + 'admin.plugins.allowedHosts.restartNote': '儲存會重新啟動外掛,以便套用新的清單。', + 'admin.plugins.allowedHosts.add': '新增允許的主機', + 'admin.plugins.allowedHosts.count': '{n} 個允許的主機', + 'admin.plugins.operatorEgressPill': '+ 你新增的主機', + 'admin.plugins.operatorEgressHint': '此外掛會連線到只有你才知道位址的服務(自架伺服器)。安裝後請在 ⋯ → 允許的主機 中新增。其他主機一律無法存取。', 'admin.plugins.noErrors': '沒有錯誤記錄。', 'admin.plugins.uninstalled': '外掛已解除安裝', 'admin.plugins.uninstallTitle': '解除安裝外掛?', @@ -278,11 +287,14 @@ const admin: TranslationStrings = { 'admin.plugins.perm.db:write:atlas': '標記操作使用者已造訪的國家與地區並管理願望清單(需要 Atlas 附加元件)', 'admin.plugins.perm.db:write:vacay': '在操作使用者的使用中計畫中切換休假日與公司假日(需要 Vacay 附加元件)', 'admin.plugins.perm.db:write:journal': '在操作使用者可編輯的日誌中建立、編輯及刪除項目(需要 Journey 附加元件)', - 'admin.plugins.perm.db:write:collections': '建立及編輯收藏並將地點儲存至其中,依操作使用者的收藏角色執行(需要 Collections 附加元件)', - 'admin.plugins.perm.db:write:files': '向操作使用者可編輯的行程附加檔案(10MB 上限,被封鎖的副檔名將被拒絕)並管理其連結', + 'admin.plugins.perm.db:write:collections': + '建立及編輯收藏並將地點儲存至其中,依操作使用者的收藏角色執行(需要 Collections 附加元件)', + 'admin.plugins.perm.db:write:files': + '向操作使用者可編輯的行程附加檔案(10MB 上限,被封鎖的副檔名將被拒絕)並管理其連結', 'admin.plugins.perm.db:write:collab': '在操作使用者可編輯的行程中發佈筆記、投票及聊天訊息(需要 Collab 附加元件)', 'admin.plugins.perm.db:write:members': '向行程新增使用者 — 授予行程存取權;受操作使用者的成員管理權限保護', - 'admin.plugins.perm.notify:send': '向操作使用者或其所屬的行程傳送通知(鈴鐺收件匣 + 郵件/ntfy)— 絕不傳送給任意接收者', + 'admin.plugins.perm.notify:send': + '向操作使用者或其所屬的行程傳送通知(鈴鐺收件匣 + 郵件/ntfy)— 絕不傳送給任意接收者', 'admin.plugins.perm.ai:invoke': '代表操作使用者執行管理員設定的 AI 供應商(文字補全 + 文件擷取)— 外掛絕不持有金鑰', 'admin.plugins.perm.oauth:client': '透過主機代理的 OAuth 代表你連線第三方服務(權杖由主機保管,外掛永遠看不到)', 'admin.plugins.perm.db:read:files': '讀取操作使用者可存取之行程的檔案', @@ -314,6 +326,7 @@ const admin: TranslationStrings = { 'admin.plugins.perm.hook:journal-entry-provider': '向日誌條目新增額外列(連結、統計)', 'admin.plugins.perm.hook:user-data': '刪除或匯出其儲存的某使用者資料(GDPR:帳戶刪除與資料請求)', 'admin.plugins.perm.hook:trip-card-provider': '在儀表板的行程卡片上加入小徽章(狀態、計數)', + 'admin.plugins.perm.hook:notification-channel': '透過額外的管道傳送你的通知', 'admin.plugins.perm.events:subscribe': '回應核心活動事件(僅事件名稱與行程,絕不包含內容)', 'admin.plugins.perm.http:outbound': '向其宣告的主機發出對外請求', 'admin.plugins.perm.db:read:collab': '讀取操作使用者可存取之行程的筆記、投票及聊天訊息(需要 Collab 附加元件)', @@ -386,6 +399,7 @@ const admin: TranslationStrings = { 'admin.plugins.cap.reservationSlot': '預訂詳情', 'admin.plugins.cap.replacesTabs': '取代行程規劃分頁', 'admin.plugins.cap.realtime': '即時更新', + 'admin.plugins.cap.notificationChannel': '通知管道', 'admin.plugins.cap.photos': '提供照片', 'admin.plugins.cap.calendar': '提供行事曆事件', 'admin.plugins.cap.placeDetails': '豐富地點', diff --git a/shared/src/i18n/zh-TW/settings.ts b/shared/src/i18n/zh-TW/settings.ts index baf842f54..02cc9f4de 100644 --- a/shared/src/i18n/zh-TW/settings.ts +++ b/shared/src/i18n/zh-TW/settings.ts @@ -86,6 +86,14 @@ const settings: TranslationStrings = { 'settings.notificationPreferences.email': '電子郵件', 'settings.notificationPreferences.webhook': 'Webhook', 'settings.notificationPreferences.inapp': '應用程式內', + 'settings.notificationPreferences.notConfigured': '尚未設定 — 請在外掛設定中進行設定', + 'settings.plugins.actions': '操作', + 'settings.plugins.actions.confirm': '執行此操作?', + 'settings.notificationPreferences.sendTest': '傳送測試', + 'settings.notificationPreferences.configure': '前往設定', + 'settings.notificationPreferences.testSuccess': '已傳送測試通知。', + 'settings.notificationPreferences.testFailed': '測試失敗。', + 'settings.notificationPreferences.pluginConfigured': '已設定。請在外掛的設定頁面管理憑證。', 'settings.notificationPreferences.ntfy': 'Ntfy', 'settings.notificationPreferences.noChannels': '未配置通知渠道。請聯絡管理員設定電子郵件或 Webhook 通知。', 'settings.webhookUrl.label': 'Webhook URL', diff --git a/shared/src/i18n/zh/admin.ts b/shared/src/i18n/zh/admin.ts index a3f6480b5..d4fc8c747 100644 --- a/shared/src/i18n/zh/admin.ts +++ b/shared/src/i18n/zh/admin.ts @@ -81,8 +81,7 @@ const admin: TranslationStrings = { 'admin.oidcLoginHint': '允许用户通过 SSO 登录', 'admin.oidcRegistration': 'SSO 自动创建账户', 'admin.oidcRegistrationHint': '为首次通过 SSO 登录的用户自动创建账户', - 'admin.envOverrideHint': - '密码登录设置由 OIDC_ONLY 环境变量控制,无法在此处修改。', + 'admin.envOverrideHint': '密码登录设置由 OIDC_ONLY 环境变量控制,无法在此处修改。', 'admin.lockoutWarning': '至少需要保留一种登录方式', 'admin.requireMfa': '要求双因素身份验证(2FA)', 'admin.requireMfaHint': '未启用 2FA 的用户必须先完成设置中的配置才能使用应用。', @@ -181,6 +180,15 @@ const admin: TranslationStrings = { 'admin.plugins.deactivated': '插件已停用', 'admin.plugins.viewErrors': '查看错误日志', 'admin.plugins.errorLog': '错误日志', + 'admin.plugins.allowedHosts': '允许的主机', + 'admin.plugins.allowedHosts.hint': '此插件会连接一个只有你才知道地址的服务(自托管服务器)。请添加它可以访问的主机 —— 其他主机一概无法访问。', + 'admin.plugins.allowedHosts.none': '尚未添加主机。', + 'admin.plugins.allowedHosts.unsupported': '此插件不使用运营者提供的主机。其允许的主机已在清单中固定。', + 'admin.plugins.allowedHosts.restartNote': '保存会重启插件,以便应用新的列表。', + 'admin.plugins.allowedHosts.add': '添加允许的主机', + 'admin.plugins.allowedHosts.count': '{n} 个允许的主机', + 'admin.plugins.operatorEgressPill': '+ 你添加的主机', + 'admin.plugins.operatorEgressHint': '此插件会连接一个只有你才知道地址的服务(自托管服务器)。安装后请在 ⋯ → 允许的主机 中添加。其他主机一概无法访问。', 'admin.plugins.noErrors': '暂无错误记录。', 'admin.plugins.uninstalled': '插件已卸载', 'admin.plugins.uninstallTitle': '卸载插件?', @@ -239,8 +247,10 @@ const admin: TranslationStrings = { 'admin.plugins.perm.db:write:atlas': '标记当前用户已访问的国家和地区并管理愿望清单(需要 Atlas 插件)', 'admin.plugins.perm.db:write:vacay': '在当前用户的活动计划中切换休假日和公司假期(需要 Vacay 插件)', 'admin.plugins.perm.db:write:journal': '在当前用户可编辑的日记中创建、编辑和删除条目(需要 Journey 插件)', - 'admin.plugins.perm.db:write:collections': '创建和编辑收藏并向其中保存地点,使用当前用户的收藏角色(需要 Collections 插件)', - 'admin.plugins.perm.db:write:files': '向当前用户可编辑的旅行附加文件(10MB 上限,被阻止的扩展名将被拒绝)并管理其链接', + 'admin.plugins.perm.db:write:collections': + '创建和编辑收藏并向其中保存地点,使用当前用户的收藏角色(需要 Collections 插件)', + 'admin.plugins.perm.db:write:files': + '向当前用户可编辑的旅行附加文件(10MB 上限,被阻止的扩展名将被拒绝)并管理其链接', 'admin.plugins.perm.db:write:collab': '在当前用户可编辑的旅行中发布笔记、投票和聊天消息(需要 Collab 插件)', 'admin.plugins.perm.db:write:members': '向旅行添加用户 — 授予旅行访问权限;受当前用户的成员管理权限保护', 'admin.plugins.perm.notify:send': '向当前用户或其所属的旅行发送通知(铃铛收件箱 + 邮件/ntfy)— 绝不发送给任意接收者', @@ -275,6 +285,7 @@ const admin: TranslationStrings = { 'admin.plugins.perm.hook:journal-entry-provider': '向日志条目添加额外行(链接、统计)', 'admin.plugins.perm.hook:user-data': '删除或导出其存储的某用户数据(GDPR:账户删除与数据请求)', 'admin.plugins.perm.hook:trip-card-provider': '在仪表盘的行程卡片上添加小徽章(状态、计数)', + 'admin.plugins.perm.hook:notification-channel': '通过额外的渠道发送你的通知', 'admin.plugins.perm.events:subscribe': '响应核心活动事件(仅事件名称和行程,绝不包含内容)', 'admin.plugins.perm.http:outbound': '向其声明的主机发出出站请求', 'admin.plugins.perm.db:read:collab': '读取当前用户可访问的旅行的笔记、投票和聊天消息(需要 Collab 插件)', @@ -348,6 +359,7 @@ const admin: TranslationStrings = { 'admin.plugins.cap.reservationSlot': '预订详情', 'admin.plugins.cap.replacesTabs': '替换行程规划标签页', 'admin.plugins.cap.realtime': '实时更新', + 'admin.plugins.cap.notificationChannel': '通知渠道', 'admin.plugins.cap.photos': '提供照片', 'admin.plugins.cap.calendar': '提供日历事件', 'admin.plugins.cap.placeDetails': '丰富地点信息', diff --git a/shared/src/i18n/zh/settings.ts b/shared/src/i18n/zh/settings.ts index ebe857ebf..1eb77615e 100644 --- a/shared/src/i18n/zh/settings.ts +++ b/shared/src/i18n/zh/settings.ts @@ -270,6 +270,14 @@ const settings: TranslationStrings = { 'settings.ntfyUrl.testFailed': '测试 Ntfy 通知失败', 'settings.ntfyUrl.tokenCleared': '访问令牌已清除', 'settings.notificationPreferences.inapp': '应用内', + 'settings.notificationPreferences.notConfigured': '尚未配置 — 请在插件设置中进行设置', + 'settings.plugins.actions': '操作', + 'settings.plugins.actions.confirm': '执行此操作?', + 'settings.notificationPreferences.sendTest': '发送测试', + 'settings.notificationPreferences.configure': '去配置', + 'settings.notificationPreferences.testSuccess': '已发送测试通知。', + 'settings.notificationPreferences.testFailed': '测试失败。', + 'settings.notificationPreferences.pluginConfigured': '已配置。请在插件的设置页面管理凭据。', 'settings.notificationPreferences.webhook': 'Webhook', 'settings.notificationPreferences.email': '电子邮件', 'settings.notificationPreferences.ntfy': 'Ntfy', @@ -348,8 +356,7 @@ const settings: TranslationStrings = { 'settings.appearance.contrastLow': '对比度偏低', 'settings.appearance.readability': '可读性', 'settings.appearance.transparency': '透明效果', - 'settings.appearance.transparencyHint': - '使用玻璃感半透明界面,关闭后改用纯色、高对比度背景。', + 'settings.appearance.transparencyHint': '使用玻璃感半透明界面,关闭后改用纯色、高对比度背景。', 'settings.appearance.reduceMotion': '减少动态效果', 'settings.appearance.reduceMotionHint': '尽量减少动画和过渡效果。', 'settings.appearance.density': '布局密度', @@ -379,8 +386,7 @@ const settings: TranslationStrings = { 'settings.appearance.group.belowHero': '顶部摘要下方', 'settings.appearance.group.bottomOfPage': '页面底部', 'settings.appearance.sidebarHint': '控制整个右侧栏,关闭后仪表盘会居中显示。', - 'settings.appearance.densityHint': - '紧凑模式会收紧间距和内边距,让屏幕显示更多内容。', + 'settings.appearance.densityHint': '紧凑模式会收紧间距和内边距,让屏幕显示更多内容。', 'settings.appearance.textSizeAll': '全部', 'settings.appearance.perSize': '分别调整每类字号', 'settings.appearance.size.large': '大',