mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
feat(notifications): let a plugin register a notification channel
TREK's four channels (in-app, email, webhook, ntfy) were a closed set: notificationService.send() dispatched with four copy-pasted `if` blocks and no provider abstraction, so a fifth channel meant editing eight files by hand. A plugin could produce a notification via ctx.notify.send(), but never deliver one. A plugin now registers a channel with `hooks.notificationChannel` + `hook:notification-channel` on a plain `type: 'integration'` — not a new manifest type, so the TREK-Plugins registry schema and both its CI gates are untouched. Core refactor - New channel registry (services/notifications/): email/webhook/ntfy become ExternalChannel providers wrapping the EXISTING send functions — no delivery logic is rewritten, only relocated. In-app deliberately stays out: it writes typed rows with scope/target/callbacks, not a rendered title+body, the same line shared/ already draws with i18n/externalNotifications. - The event text is now rendered once per recipient instead of once per channel. - The channel set is open: NotifChannel becomes a string, the matrix is registry-derived, and the UI columns are server-driven. The DB column was already bare TEXT and the Zod contract already a string record — only the TypeScript and the two UIs were ever closed. The hook runs USERLESS. Every other hook is user-initiated, so actingUserId falls out of the request; a notification is host-initiated for an ARBITRARY recipient, so ctx.settings.get() would return undefined. The host resolves the recipient's decrypted scope:'user' settings itself and passes them as an argument. That is what lets a channel plugin be handed someone's push token WITHOUT being handed the right to read their trips as them. Enabling the plugin is the opt-in: a plugin channel is not gated on the admin's `notification_channels` list. A built-in always exists in code and needs an explicit switch; a plugin channel only exists because an admin enabled that plugin. (Nothing could write a `plugin:` id into that CSV anyway, and the admin toggle rebuilt it from three booleans, silently dropping anything else — so requiring a second opt-in meant the channel could never be turned on at all.) Also fixed, found while building this: - Plugin settings keys were unvalidated, so a field named `__proto__` or `constructor` resolved off Object.prototype: a REQUIRED field with such a name reported as configured for every user who had configured nothing — enough, for a channel, to be dispatched to everyone with no credentials. Keys are now constrained at install and the config blob is parsed null-prototype, so it is impossible even for an already-installed plugin. - A `select` field's options were cast straight through, so the obvious `["1","5"]` form rendered every dropdown entry BLANK (the client reads value/label). Now coerced, and malformed options are rejected. Also adds: operator-supplied egress hosts (a plugin talking to a self-hosted service can't name the operator's host at publish time, so an admin adds it post-install and the runtime re-spawns the child with the widened allow-list — only for a plugin that DECLARED operatorEgress, and only an admin, never a user); settings-page actions (a "Test connection" button, user-initiated so ctx.settings.get() returns the clicking user's own value); and a Gotify-shaped notification-channel template in the SDK. Verified end to end against a real Gotify container, not just in tests.
This commit is contained in:
@@ -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<string, unknown> }),
|
||||
apiClient.get(`/plugin-settings/${id}`).then(r => r.data as {
|
||||
fields: PluginUserSettingField[]
|
||||
config: Record<string, unknown>
|
||||
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<string, unknown>) =>
|
||||
apiClient.post(`/plugin-settings/${id}`, { config }).then(r => r.data as { config: Record<string, unknown> }),
|
||||
// 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 = {
|
||||
|
||||
@@ -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<string, unknown> = {}) {
|
||||
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<string, unknown>) {
|
||||
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(<AdminPluginsPanel />)
|
||||
// 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(<AdminPluginsPanel />)
|
||||
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(<AdminPluginsPanel />)
|
||||
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(<AdminPluginsPanel />)
|
||||
|
||||
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<string, unknown> | 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(<AdminPluginsPanel />)
|
||||
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()
|
||||
})
|
||||
})
|
||||
@@ -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<Record<string, string>>({})
|
||||
const [detailFor, setDetailFor] = useState<RegistryItem | null>(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<PluginRow | null>(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) }} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
@@ -693,6 +730,56 @@ export default function AdminPluginsPanel() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Operator-supplied egress hosts */}
|
||||
{egressFor && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4" onClick={() => setEgressFor(null)}>
|
||||
<div className="bg-surface-card border border-edge rounded-2xl w-full max-w-lg shadow-modal" onClick={e => e.stopPropagation()}>
|
||||
<div className="px-5 py-3.5 border-b border-edge-secondary flex items-center justify-between">
|
||||
<span className="text-sm font-semibold text-content flex items-center gap-2"><Globe size={15} /> {egressFor.id} — {t('admin.plugins.allowedHosts')}</span>
|
||||
<button onClick={() => setEgressFor(null)} className="text-content-faint hover:text-content"><X size={16} /></button>
|
||||
</div>
|
||||
<div className="p-5 space-y-3">
|
||||
{!egressFor.supported ? (
|
||||
<p className="text-sm text-content-faint">{t('admin.plugins.allowedHosts.unsupported')}</p>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-xs text-content-faint">{t('admin.plugins.allowedHosts.hint')}</p>
|
||||
{egressFor.hosts.length === 0 && (
|
||||
<p className="text-sm text-content-faint italic">{t('admin.plugins.allowedHosts.none')}</p>
|
||||
)}
|
||||
{egressFor.hosts.map(h => (
|
||||
<div key={h} className="flex items-center justify-between gap-2 rounded-lg border border-edge-secondary px-3 py-2">
|
||||
<span className="text-sm font-mono text-content break-all">{h}</span>
|
||||
<button
|
||||
disabled={egressSaving}
|
||||
onClick={() => saveEgress(egressFor.hosts.filter(x => x !== h))}
|
||||
className="text-content-faint hover:text-danger disabled:opacity-50"
|
||||
aria-label={t('common.delete')}
|
||||
><Trash2 size={14} /></button>
|
||||
</div>
|
||||
))}
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
value={egressDraft}
|
||||
onChange={e => 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"
|
||||
/>
|
||||
<button
|
||||
disabled={egressSaving || !egressDraft.trim()}
|
||||
onClick={() => saveEgress([...egressFor.hosts, egressDraft.trim()])}
|
||||
className="rounded-lg bg-content px-3 py-2 text-sm text-surface disabled:opacity-50"
|
||||
>{t('common.add')}</button>
|
||||
</div>
|
||||
{egressError && <p className="text-xs text-danger">{egressError}</p>}
|
||||
<p className="text-xs text-content-faint">{t('admin.plugins.allowedHosts.restartNote')}</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ConfirmDialog
|
||||
isOpen={!!confirmUninstall}
|
||||
onClose={() => 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<string[]>(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
|
||||
<div className="flex items-center gap-1.5 mt-1.5 text-[11.5px] text-danger">
|
||||
<AlertTriangle size={13} className="shrink-0" /><span className="truncate">{p.last_error}</span>
|
||||
</div>
|
||||
) : caps.length > 0 && (
|
||||
) : (caps.length > 0 || p.operatorEgress) && (
|
||||
<div className="hidden sm:flex items-center gap-1.5 flex-wrap mt-2">
|
||||
{caps.map((c, i) => (
|
||||
<span key={i} className={`inline-flex items-center gap-1.5 text-[11px] font-medium px-2 py-[3px] rounded-md border ${
|
||||
@@ -838,6 +925,25 @@ function InstalledRow({ p, t, busy, menu, setMenu, hasUpdate, latestVer, onToggl
|
||||
<c.icon size={12} className={c.net ? 'text-info' : 'text-content-muted'} />{c.label}
|
||||
</span>
|
||||
))}
|
||||
{/* 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 && (
|
||||
<button
|
||||
onClick={onEgress}
|
||||
title={t('admin.plugins.allowedHosts.hint')}
|
||||
className={`inline-flex items-center gap-1.5 text-[11px] font-medium px-2 py-[3px] rounded-md border transition-colors ${
|
||||
p.egressHostCount > 0
|
||||
? 'text-info border-info/25 bg-info-soft hover:border-info/50'
|
||||
: 'text-warning border-warning/30 bg-warning-soft hover:border-warning/60'}`}
|
||||
>
|
||||
<Globe size={12} className={p.egressHostCount > 0 ? 'text-info' : 'text-warning'} />
|
||||
{p.egressHostCount > 0
|
||||
? t('admin.plugins.allowedHosts.count').replace('{n}', String(p.egressHostCount))
|
||||
: t('admin.plugins.allowedHosts.add')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{deps.length > 0 && (
|
||||
@@ -874,6 +980,7 @@ function InstalledRow({ p, t, busy, menu, setMenu, hasUpdate, latestVer, onToggl
|
||||
<MenuItem icon={<RotateCw size={14} />} label={t('admin.plugins.restart')} onClick={onRestart} />
|
||||
)}
|
||||
<MenuItem icon={<Bug size={14} />} label={t('admin.plugins.viewErrors')} onClick={onErrors} />
|
||||
<MenuItem icon={<Globe size={14} />} label={t('admin.plugins.allowedHosts')} onClick={onEgress} />
|
||||
{p.source_repo && p.source_repo !== 'local:upload' && p.source_repo !== 'local:link' && (
|
||||
<>
|
||||
<a href={`https://github.com/${p.source_repo}`} target="_blank" rel="noreferrer" onClick={() => setMenu(null)}
|
||||
@@ -1070,14 +1177,26 @@ function PluginDetailModal({ item, installed, busy, onInstall, onClose, t, local
|
||||
</div>
|
||||
)}
|
||||
|
||||
{manifest && manifest.egress.length > 0 && (
|
||||
{manifest && (manifest.egress.length > 0 || manifest.operatorEgress) && (
|
||||
<div className="mt-5">
|
||||
<h4 className="text-[11px] font-semibold uppercase tracking-wider text-content-muted">{t('admin.plugins.connectsTitle')}</h4>
|
||||
<div className="flex flex-wrap gap-1.5 mt-2">
|
||||
<div className="flex flex-wrap items-center gap-1.5 mt-2">
|
||||
{manifest.egress.map(h => (
|
||||
<code key={h} className="text-[12px] font-mono text-info bg-info-soft rounded-md px-2 py-1">{h}</code>
|
||||
))}
|
||||
{/* 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 && (
|
||||
<span className="inline-flex items-center gap-1.5 text-[12px] font-medium text-warning bg-warning-soft border border-warning/30 rounded-md px-2 py-1">
|
||||
<Globe size={12} />{t('admin.plugins.operatorEgressPill')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{manifest.operatorEgress && (
|
||||
<p className="text-[11.5px] text-content-faint mt-2">{t('admin.plugins.operatorEgressHint')}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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(<NotificationsTab />);
|
||||
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(<NotificationsTab />);
|
||||
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(<NotificationsTab />);
|
||||
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(<NotificationsTab />);
|
||||
@@ -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(<NotificationsTab />);
|
||||
await waitFor(() => {
|
||||
@@ -169,11 +230,13 @@ describe('NotificationsTab', () => {
|
||||
const user = userEvent.setup();
|
||||
let resolveRequest!: () => void;
|
||||
server.use(
|
||||
http.put('/api/notifications/preferences', () =>
|
||||
new Promise<Response>(resolve => {
|
||||
resolveRequest = () => resolve(HttpResponse.json({ success: true }) as unknown as Response);
|
||||
}),
|
||||
),
|
||||
http.put(
|
||||
'/api/notifications/preferences',
|
||||
() =>
|
||||
new Promise<Response>((resolve) => {
|
||||
resolveRequest = () => resolve(HttpResponse.json({ success: true }) as unknown as Response);
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
render(<NotificationsTab />);
|
||||
@@ -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(<NotificationsTab />);
|
||||
@@ -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(<NotificationsTab />);
|
||||
@@ -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(<NotificationsTab />);
|
||||
@@ -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(<NotificationsTab />);
|
||||
@@ -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(
|
||||
<>
|
||||
<NotificationsTab />
|
||||
<ToastContainer />
|
||||
</>,
|
||||
</>
|
||||
);
|
||||
|
||||
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(<NotificationsTab />);
|
||||
@@ -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(<NotificationsTab />);
|
||||
@@ -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(
|
||||
<>
|
||||
<NotificationsTab />
|
||||
<ToastContainer />
|
||||
</>,
|
||||
</>
|
||||
);
|
||||
|
||||
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(
|
||||
<>
|
||||
<NotificationsTab />
|
||||
<ToastContainer />
|
||||
</>,
|
||||
</>
|
||||
);
|
||||
|
||||
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<string, unknown> = {}) => ({
|
||||
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(<NotificationsTab />);
|
||||
// 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(<NotificationsTab />);
|
||||
|
||||
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(<NotificationsTab />);
|
||||
|
||||
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(<><NotificationsTab /><ToastContainer /></>);
|
||||
|
||||
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(<NotificationsTab />);
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<string, Record<string, boolean>>
|
||||
available_channels: { email: boolean; webhook: boolean; inapp: boolean; ntfy: boolean }
|
||||
channels: ChannelDescriptor[]
|
||||
event_types: string[]
|
||||
implemented_combos: Record<string, string[]>
|
||||
defaults?: { ntfyServer: string | null }
|
||||
}
|
||||
|
||||
const CHANNEL_LABEL_KEYS: Record<string, string> = {
|
||||
email: 'settings.notificationPreferences.email',
|
||||
webhook: 'settings.notificationPreferences.webhook',
|
||||
inapp: 'settings.notificationPreferences.inapp',
|
||||
ntfy: 'settings.notificationPreferences.ntfy',
|
||||
/** Plugin channels have no i18n — the server sends their display name outright. */
|
||||
function channelLabel(ch: ChannelDescriptor, t: (k: string) => string): string {
|
||||
if (ch.labelKey) return t(ch.labelKey) || ch.id
|
||||
return ch.label || ch.id
|
||||
}
|
||||
|
||||
const EVENT_LABEL_KEYS: Record<string, string> = {
|
||||
@@ -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<string | null>(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 (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 0 }}>
|
||||
{saving && <p style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', color: 'var(--text-faint)', marginBottom: 8 }}>{t('common.saving')}</p>}
|
||||
{matrix.available_channels.webhook && (
|
||||
{hasChannel('webhook') && (
|
||||
<div style={{ marginBottom: 16, padding: '12px', background: 'var(--bg-secondary)', borderRadius: 8, border: '1px solid var(--border-primary)' }}>
|
||||
<label style={{ display: 'block', fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontWeight: 600, color: 'var(--text-secondary)', marginBottom: 4 }}>
|
||||
{t('settings.webhookUrl.label')}
|
||||
@@ -215,7 +249,7 @@ export default function NotificationsTab(): React.ReactElement {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{matrix.available_channels.ntfy && (
|
||||
{hasChannel('ntfy') && (
|
||||
<div style={{ marginBottom: 16, padding: '12px', background: 'var(--bg-secondary)', borderRadius: 8, border: '1px solid var(--border-primary)' }}>
|
||||
<label style={{ display: 'block', fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontWeight: 600, color: 'var(--text-secondary)', marginBottom: 4 }}>
|
||||
{t('settings.ntfyUrl.topicLabel')}
|
||||
@@ -275,19 +309,50 @@ export default function NotificationsTab(): React.ReactElement {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{pluginChannels.map(ch => (
|
||||
<div key={ch.id} style={{ marginBottom: 16, padding: '12px', background: 'var(--bg-secondary)', borderRadius: 8, border: '1px solid var(--border-primary)' }}>
|
||||
<label style={{ display: 'block', fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontWeight: 600, color: 'var(--text-secondary)', marginBottom: 4 }}>
|
||||
{channelLabel(ch, t)}
|
||||
</label>
|
||||
<p style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', color: 'var(--text-faint)', marginBottom: 8 }}>
|
||||
{ch.configured
|
||||
? t('settings.notificationPreferences.pluginConfigured')
|
||||
: t('settings.notificationPreferences.notConfigured')}
|
||||
</p>
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
||||
{/* 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 && (
|
||||
<Link
|
||||
to={ch.settingsPath}
|
||||
style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', padding: '6px 12px', background: 'var(--text-primary)', color: 'var(--bg-primary)', border: 'none', borderRadius: 6, textDecoration: 'none' }}
|
||||
>
|
||||
{t('settings.notificationPreferences.configure')}
|
||||
</Link>
|
||||
)}
|
||||
<button
|
||||
onClick={() => testChannel(ch)}
|
||||
disabled={!ch.configured || channelTesting === ch.id}
|
||||
style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', padding: '6px 12px', background: 'transparent', color: 'var(--text-secondary)', border: '1px solid var(--border-primary)', borderRadius: 6, cursor: (!ch.configured || channelTesting === ch.id) ? 'not-allowed' : 'pointer', opacity: (!ch.configured || channelTesting === ch.id) ? 0.5 : 1 }}
|
||||
>
|
||||
{t('settings.notificationPreferences.sendTest')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{/* Header row */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: `1fr ${visibleChannels.map(() => '64px').join(' ')}`, gap: 4, paddingBottom: 6, marginBottom: 4, borderBottom: '1px solid var(--border-primary)' }}>
|
||||
<span />
|
||||
{visibleChannels.map(ch => (
|
||||
<span key={ch} style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 600, color: 'var(--text-faint)', textAlign: 'center', textTransform: 'uppercase', letterSpacing: '0.04em' }}>
|
||||
{t(CHANNEL_LABEL_KEYS[ch]) || ch}
|
||||
<span key={ch.id} title={!ch.configured ? t('settings.notificationPreferences.notConfigured') : undefined} style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 600, color: ch.configured ? 'var(--text-faint)' : 'var(--color-warning, #d69e2e)', textAlign: 'center', textTransform: 'uppercase', letterSpacing: '0.04em' }}>
|
||||
{channelLabel(ch, t)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
{/* 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 (
|
||||
<div key={eventType} style={{ display: 'grid', gridTemplateColumns: `1fr ${visibleChannels.map(() => '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}
|
||||
</span>
|
||||
{visibleChannels.map(ch => {
|
||||
if (!implementedForEvent.includes(ch)) {
|
||||
return <span key={ch} style={{ textAlign: 'center', color: 'var(--text-faint)', fontSize: 'calc(14px * var(--fs-scale-body, 1))' }}>—</span>
|
||||
if (!implementedForEvent.includes(ch.id)) {
|
||||
return <span key={ch.id} style={{ textAlign: 'center', color: 'var(--text-faint)', fontSize: 'calc(14px * var(--fs-scale-body, 1))' }}>—</span>
|
||||
}
|
||||
const isOn = matrix.preferences[eventType]?.[ch] ?? true
|
||||
const isOn = matrix.preferences[eventType]?.[ch.id] ?? true
|
||||
return (
|
||||
<div key={ch} style={{ display: 'flex', justifyContent: 'center' }}>
|
||||
<ToggleSwitch on={isOn} onToggle={() => toggle(eventType, ch)} />
|
||||
<div key={ch.id} style={{ display: 'flex', justifyContent: 'center' }}>
|
||||
<ToggleSwitch on={isOn} onToggle={() => toggle(eventType, ch.id)} />
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
@@ -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<Record<string, string | boolean>>({})
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [oauth, setOauth] = useState<{ configured: boolean; connected: boolean } | null>(null)
|
||||
const [actions, setActions] = useState<PluginAction[]>([])
|
||||
const [running, setRunning] = useState<string | null>(null)
|
||||
const [actionResult, setActionResult] = useState<Record<string, { ok: boolean; message?: string }>>({})
|
||||
|
||||
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<string, string | boolean> = {}
|
||||
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')}
|
||||
</button>
|
||||
)}
|
||||
{actions.length > 0 && (
|
||||
<div className="mt-4 border-t border-border pt-4">
|
||||
<span className="block text-xs font-semibold uppercase tracking-wide text-content-muted mb-2">
|
||||
{t('settings.plugins.actions')}
|
||||
</span>
|
||||
<div className="flex flex-col gap-2">
|
||||
{actions.map(a => {
|
||||
const res = actionResult[a.key]
|
||||
return (
|
||||
<div key={a.key} className="flex flex-wrap items-center gap-2">
|
||||
<button
|
||||
onClick={() => runAction(a)}
|
||||
disabled={running !== null}
|
||||
className={`inline-flex items-center gap-2 rounded-lg border px-3 py-1.5 text-sm font-semibold disabled:opacity-60 ${
|
||||
a.danger ? 'border-danger text-danger' : 'border-border text-content'
|
||||
}`}
|
||||
>
|
||||
{running === a.key && <Loader2 className="w-4 h-4 animate-spin" />}
|
||||
{a.label}
|
||||
</button>
|
||||
{a.hint && <span className="text-xs text-content-muted">{a.hint}</span>}
|
||||
{res && (
|
||||
<span className={`text-xs font-medium ${res.ok ? 'text-success' : 'text-danger'}`}>
|
||||
{res.message || (res.ok ? t('common.success') : t('common.error'))}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<PluginOAuthSection id={id} state={oauth} setState={setOauth} />
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -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<string, unknown> | null = null;
|
||||
server.use(
|
||||
http.put('/api/auth/app-settings', async ({ request }) => {
|
||||
capturedBody = await request.json() as Record<string, unknown>;
|
||||
capturedBody = (await request.json()) as Record<string, unknown>;
|
||||
return HttpResponse.json({});
|
||||
}),
|
||||
})
|
||||
);
|
||||
|
||||
seedStore(useAuthStore, { isAuthenticated: true, user: buildAdmin() });
|
||||
@@ -462,9 +461,9 @@ describe('AdminPage', () => {
|
||||
let capturedBody: Record<string, unknown> | null = null;
|
||||
server.use(
|
||||
http.put('/api/auth/app-settings', async ({ request }) => {
|
||||
capturedBody = await request.json() as Record<string, unknown>;
|
||||
capturedBody = (await request.json()) as Record<string, unknown>;
|
||||
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<string, unknown> | null = null;
|
||||
server.use(
|
||||
http.put('/api/auth/app-settings', async ({ request }) => {
|
||||
capturedBody = await request.json() as Record<string, unknown>;
|
||||
capturedBody = (await request.json()) as Record<string, unknown>;
|
||||
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<string, unknown> | null = null;
|
||||
server.use(
|
||||
http.put('/api/auth/app-settings', async ({ request }) => {
|
||||
capturedBody = await request.json() as Record<string, unknown>;
|
||||
capturedBody = (await request.json()) as Record<string, unknown>;
|
||||
return HttpResponse.json({});
|
||||
}),
|
||||
})
|
||||
);
|
||||
|
||||
seedStore(useAuthStore, { isAuthenticated: true, user: buildAdmin() });
|
||||
@@ -816,16 +848,16 @@ describe('AdminPage', () => {
|
||||
let capturedBody: Record<string, unknown> | null = null;
|
||||
server.use(
|
||||
http.put('/api/auth/app-settings', async ({ request }) => {
|
||||
capturedBody = await request.json() as Record<string, unknown>;
|
||||
capturedBody = (await request.json()) as Record<string, unknown>;
|
||||
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<string, unknown>;
|
||||
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');
|
||||
|
||||
@@ -15,8 +15,11 @@ export default function AdminNotificationsPanel({ t, toast }: { t: (k: string) =
|
||||
|
||||
if (!matrix) return <p className="text-content-faint" style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontStyle: 'italic', padding: 16 }}>Loading…</p>
|
||||
|
||||
// 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))
|
||||
})
|
||||
|
||||
|
||||
@@ -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'))
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
+153
-13
@@ -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<string, unknown> = {
|
||||
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:<host>, 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:<host> 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 <name> [--type integration|page|widget|trip-page]');
|
||||
console.error('usage: create-trek-plugin <name> [--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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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[]) : [];
|
||||
|
||||
@@ -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)' },
|
||||
|
||||
@@ -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<Photo | null>;
|
||||
}
|
||||
/**
|
||||
* 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<string, unknown>, ctx: PluginContext): Promise<void>;
|
||||
/** Backs the "Send test" button in the user's notification settings. */
|
||||
test?(config: Record<string, unknown>, ctx: PluginContext): Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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> | 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<string, (ctx: PluginContext) => Promise<PluginActionResult | void> | 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<string, PluginExport>;
|
||||
|
||||
@@ -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<Record<string, unknown>>) {
|
||||
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<string>();
|
||||
for (const a of m.actions as Array<Record<string, unknown>>) {
|
||||
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);
|
||||
|
||||
|
||||
@@ -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<typeof scaffold>[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/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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<ChannelTestResult> {
|
||||
return this.notifications.testChannel(user.id, channelId);
|
||||
}
|
||||
|
||||
@Get('in-app')
|
||||
listInApp(
|
||||
@CurrentUser() user: User,
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
respondToBoolean,
|
||||
} from '../../services/inAppNotifications';
|
||||
import { getPreferencesMatrix, setPreferences } from '../../services/notificationPreferencesService';
|
||||
import { getChannel } from '../../services/notifications/channelRegistry';
|
||||
|
||||
type NtfyConfig = ReturnType<typeof getAdminNtfyConfig>;
|
||||
type RespondResult = Awaited<ReturnType<typeof respondToBoolean>>;
|
||||
@@ -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<ChannelTestResult> {
|
||||
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<typeof setPreferences>[1]): void {
|
||||
setPreferences(userId, body);
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
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<string, unknown> => !!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<string, unknown>;
|
||||
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;
|
||||
|
||||
@@ -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 `<plugins>/<id>` 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<string[]> {
|
||||
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<void> {
|
||||
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<string, unknown>).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/<id>` 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;
|
||||
|
||||
@@ -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<string, unknown> } {
|
||||
get(@Param('id') id: string, @Req() req: Request & { user?: { id: number } }): {
|
||||
fields: unknown[];
|
||||
config: Record<string, unknown>;
|
||||
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')
|
||||
|
||||
@@ -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 } = {}) {
|
||||
|
||||
@@ -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<string, unknown> {
|
||||
const empty = () => Object.create(null) as Record<string, unknown>;
|
||||
try {
|
||||
const v = JSON.parse(json || '{}');
|
||||
return v && typeof v === 'object' && !Array.isArray(v) ? (v as Record<string, unknown>) : {};
|
||||
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<string, unknown>)) 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<string, unknown> {
|
||||
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<string, unknown> = 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<string, unknown>, secretKeys: Set<string>): Record<string, unknown> {
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const [k, v] of Object.entries(config)) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
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,
|
||||
|
||||
@@ -177,6 +177,16 @@ async function handleInvoke(req: { id: string; method: string; params: Record<st
|
||||
if (!impl || typeof impl[fnName] !== 'function') throw new Error(`no hook ${hookName}.${fnName}`);
|
||||
const result = await impl[fnName](...args, invCtx);
|
||||
respond(true, result);
|
||||
} else if (req.method === 'invoke.action') {
|
||||
// A settings-page button the user clicked. USER-INITIATED: invCtx carries the
|
||||
// clicking user, so ctx.settings.get() returns THEIR value and trip reads are
|
||||
// membership-checked against them (unlike a job or the notificationChannel hook).
|
||||
const key = req.params.key as string;
|
||||
const actions = def.actions as Record<string, ((c: PluginContext) => 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
|
||||
|
||||
@@ -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<Photo | null>;
|
||||
}
|
||||
/**
|
||||
* 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<string, unknown>, ctx: PluginContext): Promise<void>;
|
||||
/** Backs the "Send test" button in the user's notification settings. */
|
||||
test?(config: Record<string, unknown>, ctx: PluginContext): Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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> | 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<string, (ctx: PluginContext) => Promise<PluginActionResult | void> | 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<string, PluginExport>;
|
||||
|
||||
@@ -105,6 +105,7 @@ const HOOK_PERMISSION: Readonly<Record<string, string>> = {
|
||||
atlasLayerProvider: 'hook:atlas-layer-provider',
|
||||
journalEntryProvider: 'hook:journal-entry-provider',
|
||||
tripCardProvider: 'hook:trip-card-provider',
|
||||
notificationChannel: 'hook:notification-channel',
|
||||
};
|
||||
|
||||
export class PluginSupervisor {
|
||||
|
||||
@@ -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:<id>` 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<NotifEventType, NotifChannel[]> = {
|
||||
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<string, NotifChannel[]> {
|
||||
const out: Record<string, NotifChannel[]> = {};
|
||||
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<NotifEventType>(['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<Record<NotifEventType, Partial<Record<NotifChannel, boolean>>>>;
|
||||
available_channels: AvailableChannels;
|
||||
/** The columns to render, in order. Replaces the old fixed-shape available_channels. */
|
||||
channels: ChannelDescriptor[];
|
||||
event_types: NotifEventType[];
|
||||
implemented_combos: Record<NotifEventType, NotifChannel[]>;
|
||||
implemented_combos: Record<string, NotifChannel[]>;
|
||||
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<string, boolean> = { 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<Record<NotifEventType, Partial<Record<NotifChannel, boolean>>>> = {};
|
||||
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');
|
||||
|
||||
@@ -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<void> {
|
||||
const { event, actorId, params, scope, targetId, inApp } = payload;
|
||||
|
||||
@@ -202,6 +223,7 @@ export async function send(payload: NotificationPayload): Promise<void> {
|
||||
}
|
||||
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<void> {
|
||||
);
|
||||
}
|
||||
|
||||
// ── 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<void> {
|
||||
}
|
||||
}));
|
||||
|
||||
// ── 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}`);
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
@@ -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/<locale>/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<unknown>;
|
||||
sendGlobal?(msg: ChannelMessage): Promise<unknown>;
|
||||
test?(userId: number, override?: Record<string, unknown>): 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<string, ExternalChannel>();
|
||||
|
||||
/** 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<string>();
|
||||
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<string, unknown>) => 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;
|
||||
}
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
});
|
||||
@@ -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<string, unknown>) => ({ ...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', () => {
|
||||
|
||||
@@ -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');`);
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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<string, unknown>) {
|
||||
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<string, unknown> } }).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;
|
||||
});
|
||||
});
|
||||
@@ -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> = {}): 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
|
||||
});
|
||||
});
|
||||
@@ -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> = {}): 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);
|
||||
});
|
||||
});
|
||||
@@ -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']);
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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<ExternalChannel> = {}): 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);
|
||||
});
|
||||
});
|
||||
|
||||
+49
-19
@@ -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': 'يُثري الأماكن',
|
||||
|
||||
@@ -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
|
||||
|
||||
+59
-24
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
+51
-20
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
+57
-23
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
+45
-17
@@ -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',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
+63
-26
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
+63
-26
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
+71
-30
@@ -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': 'Εμπλουτίζει μέρη',
|
||||
|
||||
@@ -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': 'Πρόσθετο',
|
||||
|
||||
+67
-28
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
+65
-27
@@ -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',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
+65
-27
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
+41
-15
@@ -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': '場所を充実',
|
||||
|
||||
@@ -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',
|
||||
|
||||
+35
-12
@@ -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': '장소 정보 강화',
|
||||
|
||||
@@ -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':
|
||||
'알림 채널이 설정되지 않았습니다. 관리자에게 이메일 또는 웹훅 알림 설정을 요청하세요.',
|
||||
|
||||
+61
-25
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
+61
-25
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
+63
-26
@@ -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': 'Обогащает места',
|
||||
|
||||
@@ -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',
|
||||
|
||||
+59
-24
@@ -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 resevyer (bokningar, platser, dagar)',
|
||||
'admin.plugins.perm.hook:table-contributor':
|
||||
'Lägga till kolumner och åtgärder i resevyer (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',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
+63
-26
@@ -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',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
+59
-24
@@ -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': 'Збагачує місця',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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': '豐富地點',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': '丰富地点信息',
|
||||
|
||||
@@ -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': '大',
|
||||
|
||||
Reference in New Issue
Block a user