From 0067b0ff19c9a53b8f78899357260f9e1bacdc5f Mon Sep 17 00:00:00 2001 From: Maurice Date: Mon, 26 Oct 2026 00:48:13 +0100 Subject: [PATCH] fix(plugins): let an admin run a plugin outside its TREK range, and tidy the plugins panel TREK 5.0 left most registry plugins blocked because their authors still declare "<5.0.0", and the only way past that was TREK_PLUGINS_IGNORE_TREK_RANGE for every plugin at once. An admin can now install or switch on one such plugin after a warning; the consent is kept on its row for that plugin version and TREK major, so an update or a new major asks again. The environment switch works as before. Discover marks a plugin not confirmed for this TREK on its card, and Install opens the warning first. Admin > Plugins reads cleaner: one row of alike facts per plugin with each state that needs attention on its own line, type, status and order behind one Filter button, and the containment notes in a dialog behind the shield in the header. The row, the filter menu and the dialog moved into their own files. --- client/scripts/rtl-baseline.json | 2 +- client/scripts/size-baseline.json | 2 +- client/src/api/client.surface.test.ts | 1 + client/src/api/client.ts | 11 +- .../Admin/AdminPluginsPanel.test.tsx | 97 ++-- .../components/Admin/AdminPluginsPanel.tsx | 417 ++--------------- .../Admin/plugins/PluginFilterMenu.tsx | 167 +++++++ .../Admin/plugins/PluginInstalledRow.tsx | 426 ++++++++++++++++++ .../Admin/plugins/PluginSecurityDialog.tsx | 72 +++ .../components/Admin/plugins/pluginMenu.ts | 41 ++ .../Admin/plugins/pluginModel.test.ts | 10 +- .../components/Admin/plugins/pluginModel.ts | 3 +- .../Admin/plugins/usePluginsAdmin.test.ts | 34 +- .../Admin/plugins/usePluginsAdmin.ts | 44 +- .../components/Admin/useRangeBypass.test.ts | 31 +- client/src/components/Admin/useRangeBypass.ts | 83 +++- .../screens/admin/MAdminPluginsPanel.tsx | 2 +- .../mobile/admin/MAdminPluginsPanel.test.tsx | 22 +- server/scripts/size-baseline.json | 2 +- server/src/db/entities/Plugins.entity.ts | 4 + server/src/db/kysely/Plugins.table.ts | 2 + ...can_run_a_plugin_outside_its_trek_range.ts | 18 + .../src/db/repositories/Plugins.repository.ts | 32 +- .../src/nest/plugins/install/host-compat.ts | 97 +++- .../nest/plugins/plugin-runtime.service.ts | 37 +- server/src/nest/plugins/plugins.controller.ts | 13 +- server/src/nest/plugins/plugins.service.ts | 21 +- .../nest/plugins/registry/registry.service.ts | 49 +- .../integration/plugins/dev-link.test.ts | 4 +- .../plugins/plugin-runtime.test.ts | 56 ++- .../integration/plugins/registry.test.ts | 51 ++- server/tests/unit/db/legacy-baseline.test.ts | 1 + server/tests/unit/plugins/host-compat.test.ts | 40 +- .../unit/plugins/plugins-service.test.ts | 24 +- shared/src/i18n/ar/admin.ts | 12 +- shared/src/i18n/az/admin.ts | 12 +- shared/src/i18n/br/admin.ts | 12 +- shared/src/i18n/ca/admin.ts | 12 +- shared/src/i18n/cs/admin.ts | 12 +- shared/src/i18n/de/admin.ts | 12 +- shared/src/i18n/en/admin.ts | 12 +- shared/src/i18n/es/admin.ts | 12 +- shared/src/i18n/et/admin.ts | 12 +- shared/src/i18n/fr/admin.ts | 12 +- shared/src/i18n/gr/admin.ts | 12 +- shared/src/i18n/hu/admin.ts | 12 +- shared/src/i18n/id/admin.ts | 12 +- shared/src/i18n/it/admin.ts | 12 +- shared/src/i18n/ja/admin.ts | 12 +- shared/src/i18n/ko/admin.ts | 12 +- shared/src/i18n/nl/admin.ts | 12 +- shared/src/i18n/pl/admin.ts | 12 +- shared/src/i18n/ru/admin.ts | 12 +- shared/src/i18n/sk/admin.ts | 12 +- shared/src/i18n/sv/admin.ts | 12 +- shared/src/i18n/th/admin.ts | 12 +- shared/src/i18n/tr/admin.ts | 12 +- shared/src/i18n/uk/admin.ts | 12 +- shared/src/i18n/vi/admin.ts | 12 +- shared/src/i18n/zh-TW/admin.ts | 12 +- shared/src/i18n/zh/admin.ts | 12 +- shared/src/plugins/plugins.schema.ts | 8 +- 62 files changed, 1673 insertions(+), 575 deletions(-) create mode 100644 client/src/components/Admin/plugins/PluginFilterMenu.tsx create mode 100644 client/src/components/Admin/plugins/PluginInstalledRow.tsx create mode 100644 client/src/components/Admin/plugins/PluginSecurityDialog.tsx create mode 100644 client/src/components/Admin/plugins/pluginMenu.ts create mode 100644 server/src/db/migrations/Migration20200101046310_an_admin_can_run_a_plugin_outside_its_trek_range.ts diff --git a/client/scripts/rtl-baseline.json b/client/scripts/rtl-baseline.json index 9df5297f2..f068a4fde 100644 --- a/client/scripts/rtl-baseline.json +++ b/client/scripts/rtl-baseline.json @@ -1,5 +1,5 @@ { - "components/Admin/AdminPluginsPanel.tsx": 2, + "components/Admin/plugins/pluginMenu.ts": 2, "components/Budget/BudgetPanelMemberChips.tsx": 6, "components/Budget/CostsPanel.tsx": 3, "components/Budget/ReceiptPreviewModal.tsx": 2, diff --git a/client/scripts/size-baseline.json b/client/scripts/size-baseline.json index 885622ef4..e2a571f46 100644 --- a/client/scripts/size-baseline.json +++ b/client/scripts/size-baseline.json @@ -2,7 +2,7 @@ "e2e/help/fixtures.ts": 2291, "src/api/client.ts": 1873, "src/components/Admin/AdminPluginsPanel.test.tsx": 2704, - "src/components/Admin/AdminPluginsPanel.tsx": 1667, + "src/components/Admin/AdminPluginsPanel.tsx": 1277, "src/components/Budget/CostsPanel.test.tsx": 2736, "src/components/Budget/CostsPanel.tsx": 1798, "src/components/Map/MapView.tsx": 1385, diff --git a/client/src/api/client.surface.test.ts b/client/src/api/client.surface.test.ts index 9e76b13b1..d59b6f1d7 100644 --- a/client/src/api/client.surface.test.ts +++ b/client/src/api/client.surface.test.ts @@ -692,6 +692,7 @@ describe('client > request payloads', () => { it('FE-APISURF-030: adminApi.pluginActivate only sends consent when granted', async () => { expect((await traceOne(() => adminApi.pluginActivate('koffi'))).body).toEqual({}) expect((await traceOne(() => adminApi.pluginActivate('koffi', true))).body).toEqual({ consent: true }) + expect((await traceOne(() => adminApi.pluginActivate('koffi', false, true))).body).toEqual({ acceptTrekRange: true }) }) it('FE-APISURF-031: adminApi.pluginInstall spreads its options next to the id', async () => { diff --git a/client/src/api/client.ts b/client/src/api/client.ts index a95910619..9d75b8186 100644 --- a/client/src/api/client.ts +++ b/client/src/api/client.ts @@ -65,6 +65,7 @@ import { type PluginInstanceConfigUpdated, type PluginActionDescriptor, type PluginActionResult, + type PluginActivateRequest, type PluginInstallRequest, RoadtripDayTrack, RoadtripVia, @@ -574,9 +575,15 @@ export const adminApi = { plugins: () => apiClient.get('/admin/plugins').then(r => r.data), pluginBrowse: (refresh?: boolean) => apiClient.get('/admin/plugins/registry', { params: refresh ? { refresh: 1 } : undefined }).then(r => r.data), pluginDetail: (id: string) => apiClient.get(`/admin/plugins/registry/${encodeURIComponent(id)}`).then(r => r.data), - pluginInstall: (id: string, opts?: Pick) => + pluginInstall: (id: string, opts?: Pick) => apiClient.post('/admin/plugins/install', { id, ...opts } satisfies PluginInstallRequest).then(r => r.data), - pluginActivate: (id: string, consent?: boolean) => apiClient.post(`/admin/plugins/${id}/activate`, consent ? { consent: true } : {}).then(r => r.data), + pluginActivate: (id: string, consent?: boolean, acceptTrekRange?: boolean) => + apiClient + .post(`/admin/plugins/${id}/activate`, { + ...(consent ? { consent: true } : {}), + ...(acceptTrekRange ? { acceptTrekRange: true } : {}), + } satisfies PluginActivateRequest) + .then(r => r.data), pluginDeactivate: (id: string) => apiClient.post(`/admin/plugins/${id}/deactivate`).then(r => r.data), // `version` pins the exact version to install (the rollback path); omitted, the server // resolves the newest TREK-compatible version itself. diff --git a/client/src/components/Admin/AdminPluginsPanel.test.tsx b/client/src/components/Admin/AdminPluginsPanel.test.tsx index 3c645ba97..c32858068 100644 --- a/client/src/components/Admin/AdminPluginsPanel.test.tsx +++ b/client/src/components/Admin/AdminPluginsPanel.test.tsx @@ -549,9 +549,16 @@ function panelRoot(container: HTMLElement): HTMLElement { } /** Opens one of the toolbar dropdowns (identified by its current title) and picks an option. */ -async function pickFilter(trigger: RegExp, option: RegExp) { - fireEvent.click(await screen.findByTitle(trigger)) - fireEvent.click(await screen.findByRole('button', { name: option })) +/** The toolbar's one Filter button: type, status and order sit in its menu. */ +function filterButton(): HTMLElement { + return screen.getByRole('button', { name: /^Filter/ }) +} + +/** Picks one entry of the filter menu, opening it first when it is closed (a pick leaves it open). */ +async function pickFilter(option: RegExp) { + await screen.findByRole('button', { name: /^Filter/ }) + if (filterButton().getAttribute('aria-expanded') !== 'true') fireEvent.click(filterButton()) + fireEvent.click(await screen.findByRole('menuitemradio', { name: option })) } /** The enable toggle of a named installed row. */ @@ -610,9 +617,9 @@ describe('AdminPluginsPanel — load states and toolbar', () => { render() await screen.findByText('Gotify') - await pickFilter(/^Type: All types$/, /^Widget$/) + await pickFilter(/^Widget$/) - expect(await screen.findByTitle('Type: Widget')).toBeInTheDocument() + expect(await screen.findByRole('menuitemradio', { name: 'Widget' })).toHaveAttribute('aria-checked', 'true') expect(screen.getByText('Countdown')).toBeInTheDocument() expect(screen.queryByText('Gotify')).not.toBeInTheDocument() }) @@ -626,15 +633,15 @@ describe('AdminPluginsPanel — load states and toolbar', () => { render() await screen.findByText('OnOne') - await pickFilter(/^Status: All$/, /^Off$/) + await pickFilter(/^Off$/) expect(await screen.findByText('OffOne')).toBeInTheDocument() expect(screen.queryByText('OnOne')).not.toBeInTheDocument() - await pickFilter(/^Status: Off$/, /^Error$/) + await pickFilter(/^Error$/) expect(await screen.findByText('BadOne')).toBeInTheDocument() expect(screen.getByText('boom')).toBeInTheDocument() - await pickFilter(/^Status: Error$/, /^Update available$/) + await pickFilter(/^Update available$/) expect(await screen.findByText('OnOne')).toBeInTheDocument() expect(screen.queryByText('BadOne')).not.toBeInTheDocument() }) @@ -644,11 +651,12 @@ describe('AdminPluginsPanel — load states and toolbar', () => { render() await screen.findByText('Gotify') - await pickFilter(/^Sort: Name$/, /^Updates first$/) - expect(await screen.findByTitle('Sort: Updates first')).toBeInTheDocument() + await pickFilter(/^Updates first$/) + expect(filterButton()).toHaveTextContent('Updates first') await clickDiscover() - await waitFor(() => expect(screen.getByTitle('Sort: Name')).toBeInTheDocument()) + await waitFor(() => expect(filterButton()).not.toHaveTextContent('Updates first')) + expect(filterButton()).toHaveTextContent('Name') }) it('FE-COMP-PLUGINS-PANEL-008: rescan re-pulls the registry past its cache and confirms with a toast', async () => { @@ -1663,15 +1671,16 @@ describe('AdminPluginsPanel — Discover cards and the detail modal', () => { }) }) -describe('AdminPluginsPanel — security footer', () => { - it('FE-COMP-PLUGINS-PANEL-048: the containment note expands into its sections', async () => { +describe('AdminPluginsPanel — security dialog', () => { + it('FE-COMP-PLUGINS-PANEL-048: the shield in the header opens how plugins are contained', async () => { panelWith([]) render() await screen.findByText('No plugins installed yet.') expect(screen.queryByText('Every plugin runs boxed in')).not.toBeInTheDocument() - fireEvent.click(screen.getByText('How plugins are contained — and the limits')) + fireEvent.click(screen.getByRole('button', { name: 'How plugins are secured' })) + expect(screen.getByRole('dialog', { name: 'How plugins are secured' })).toBeInTheDocument() expect(screen.getByText('Every plugin runs boxed in')).toBeInTheDocument() expect(screen.getByText('What "Reviewed" means')).toBeInTheDocument() expect(screen.getByText('What "Signed" means')).toBeInTheDocument() @@ -1700,9 +1709,9 @@ describe('AdminPluginsPanel toolbar and dialogs', () => { row({ id: 'c-page', name: 'Gamma Page', description: 'a page', type: 'page', enabled: 1, status: 'error', last_error: 'boom' }), ] - const pickFilter = async (user: ReturnType, filter: RegExp, option: RegExp) => { - await user.click(screen.getByTitle(filter)) - await user.click(screen.getByRole('button', { name: option })) + const pickFilter = async (user: ReturnType, option: RegExp) => { + if (filterButton().getAttribute('aria-expanded') !== 'true') await user.click(filterButton()) + await user.click(screen.getByRole('menuitemradio', { name: option })) } it('FE-W5PLG-001: the search box narrows the installed list by name and description', async () => { @@ -1734,7 +1743,7 @@ describe('AdminPluginsPanel toolbar and dialogs', () => { render() await screen.findByText('Alpha Widget') - await pickFilter(user, /^Type:/, /^Widget$/) + await pickFilter(user, /^Widget$/) await waitFor(() => expect(screen.queryByText('Beta Bridge')).not.toBeInTheDocument()) expect(screen.getByText('Alpha Widget')).toBeInTheDocument() @@ -1746,15 +1755,15 @@ describe('AdminPluginsPanel toolbar and dialogs', () => { render() await screen.findByText('Alpha Widget') - await pickFilter(user, /^Status:/, /^Active$/) + await pickFilter(user, /^Active$/) await waitFor(() => expect(screen.queryByText('Beta Bridge')).not.toBeInTheDocument()) expect(screen.queryByText('Gamma Page')).not.toBeInTheDocument() - await pickFilter(user, /^Status:/, /^Off$/) + await pickFilter(user, /^Off$/) await waitFor(() => expect(screen.getByText('Beta Bridge')).toBeInTheDocument()) expect(screen.queryByText('Alpha Widget')).not.toBeInTheDocument() - await pickFilter(user, /^Status:/, /^Error$/) + await pickFilter(user, /^Error$/) await waitFor(() => expect(screen.getByText('Gamma Page')).toBeInTheDocument()) expect(screen.queryByText('Alpha Widget')).not.toBeInTheDocument() }) @@ -1766,13 +1775,13 @@ describe('AdminPluginsPanel toolbar and dialogs', () => { await screen.findByText('Alpha Widget') await waitFor(() => expect(screen.getByText(/updates? available/i)).toBeInTheDocument()) - await pickFilter(user, /^Sort:/, /^Updates first$/) + await pickFilter(user, /^Updates first$/) await waitFor(() => { const names = screen.getAllByText(/Alpha Widget|Beta Bridge|Gamma Page/).map(e => e.textContent) expect(names[0]).toBe('Gamma Page') }) - await pickFilter(user, /^Status:/, /^Update available$/) + await pickFilter(user, /^Update available$/) await waitFor(() => expect(screen.queryByText('Alpha Widget')).not.toBeInTheDocument()) expect(screen.getByText('Gamma Page')).toBeInTheDocument() }) @@ -1783,11 +1792,12 @@ describe('AdminPluginsPanel toolbar and dialogs', () => { render() await screen.findByText('Alpha Widget') - await pickFilter(user, /^Sort:/, /^Updates first$/) - expect(screen.getByTitle(/^Sort: Updates first$/)).toBeInTheDocument() + await pickFilter(user, /^Updates first$/) + expect(filterButton()).toHaveTextContent('Updates first') await user.click(screen.getByRole('tab', { name: /Discover/ })) - await waitFor(() => expect(screen.getByTitle(/^Sort: Name$/)).toBeInTheDocument()) + await waitFor(() => expect(filterButton()).not.toHaveTextContent('Updates first')) + expect(filterButton()).toHaveTextContent('Name') }) it('FE-W5PLG-007: the registry sorts by downloads and by review date', async () => { @@ -1802,19 +1812,19 @@ describe('AdminPluginsPanel toolbar and dialogs', () => { await user.click(screen.getByRole('tab', { name: /Discover/ })) await screen.findByText('Old Tool') - await pickFilter(user, /^Sort:/, /^Most downloads$/) + await pickFilter(user, /^Most downloads$/) await waitFor(() => { const names = screen.getAllByText(/^(Old|New) Tool$/).map(e => e.textContent) expect(names[0]).toBe('New Tool') }) - await pickFilter(user, /^Sort:/, /^Recently updated$/) + await pickFilter(user, /^Recently updated$/) await waitFor(() => { const names = screen.getAllByText(/^(Old|New) Tool$/).map(e => e.textContent) expect(names[0]).toBe('New Tool') }) - await pickFilter(user, /^Sort:/, /^Name$/) + await pickFilter(user, /^Name$/) await waitFor(() => { const names = screen.getAllByText(/^(Old|New) Tool$/).map(e => e.textContent) expect(names[0]).toBe('New Tool') @@ -1833,7 +1843,7 @@ describe('AdminPluginsPanel toolbar and dialogs', () => { await user.click(screen.getByRole('tab', { name: /Discover/ })) await screen.findByText('Mapper') - await pickFilter(user, /^Type:/, /^Page$/) + await pickFilter(user, /^Page$/) await waitFor(() => expect(screen.queryByText('Mapper')).not.toBeInTheDocument()) expect(screen.getByText('Poster')).toBeInTheDocument() @@ -1962,12 +1972,12 @@ describe('AdminPluginsPanel toolbar and dialogs', () => { const { container } = render() await screen.findByText('Alpha Widget') - await user.click(screen.getByTitle(/^Type:/)) - expect(screen.getByRole('button', { name: /^Widget$/ })).toBeInTheDocument() + await user.click(filterButton()) + expect(screen.getByRole('menuitemradio', { name: /^Widget$/ })).toBeInTheDocument() const layer = container.querySelector('.fixed.inset-0.z-20') as HTMLElement fireEvent.click(layer) - await waitFor(() => expect(screen.queryByRole('button', { name: /^Widget$/ })).not.toBeInTheDocument()) + await waitFor(() => expect(screen.queryByRole('menuitemradio', { name: /^Widget$/ })).not.toBeInTheDocument()) }) }) @@ -2004,12 +2014,13 @@ describe('AdminPluginsPanel registry cards and dependency chips', () => { await screen.findByText('Alpha Widget') await openDiscover(user) - await user.click(screen.getByTitle(/^Sort:/)) - await user.click(screen.getByRole('button', { name: /^Most downloads$/ })) - expect(screen.getByTitle(/^Sort: Most downloads$/)).toBeInTheDocument() + await user.click(filterButton()) + await user.click(screen.getByRole('menuitemradio', { name: /^Most downloads$/ })) + expect(filterButton()).toHaveTextContent('Most downloads') await user.click(screen.getByRole('tab', { name: /Installed/ })) - await waitFor(() => expect(screen.getByTitle(/^Sort: Name$/)).toBeInTheDocument()) + await waitFor(() => expect(filterButton()).not.toHaveTextContent('Most downloads')) + expect(filterButton()).toHaveTextContent('Name') }) it('FE-W5PLG-017: an outrun release offers the newest compatible version instead of a dead button', async () => { @@ -2026,9 +2037,9 @@ describe('AdminPluginsPanel registry cards and dependency chips', () => { expect(offer).not.toBeDisabled() expect(offer).toHaveAttribute('title', expect.stringContaining('3.5.0')) - const dead = screen.getByRole('button', { name: /^Incompatible$/ }) - expect(dead).toBeDisabled() - expect(dead.getAttribute('title')).toBeTruthy() + // Nothing fits: no dead button any more, but a card that says so before anything installs. + expect(screen.queryByRole('button', { name: /^Incompatible$/ })).not.toBeInTheDocument() + expect(screen.getByText(/^Not confirmed for TREK .+ yet$/)).toBeInTheDocument() }) it('FE-W5PLG-018: an already installed registry entry cannot be installed again', async () => { @@ -2556,7 +2567,7 @@ describe('AdminPluginsPanel — TREK-range bypass (TREK_PLUGINS_IGNORE_TREK_RANG expect(await screen.findByText('Version checks off')).toBeInTheDocument() }) - it('FE-PLG-BYPASS-002: offers "Install anyway" for an incompatible entry and asks before installing', async () => { + it('FE-PLG-BYPASS-002: offers Install for an incompatible entry and asks before installing', async () => { let posted: unknown = null bypassPanel([plugin({ id: 'something-else' })], incompatibleEntry()) server.use(http.post('*/api/admin/plugins/install', async ({ request }) => { @@ -2566,7 +2577,7 @@ describe('AdminPluginsPanel — TREK-range bypass (TREK_PLUGINS_IGNORE_TREK_RANG withToast() fireEvent.click(await screen.findByText('Discover')) - const btn = await screen.findByRole('button', { name: /^install anyway$/i }) + const btn = await screen.findByRole('button', { name: /^install$/i }) expect(btn).toBeEnabled() fireEvent.click(btn) @@ -2585,7 +2596,7 @@ describe('AdminPluginsPanel — TREK-range bypass (TREK_PLUGINS_IGNORE_TREK_RANG server.use(http.post('*/api/admin/plugins/install', () => { posted = true; return HttpResponse.json({ id: 'trek-gotify', version: '2.0.0', trekRangeBypassed: null }) })) withToast() fireEvent.click(await screen.findByText('Discover')) - fireEvent.click(await screen.findByRole('button', { name: /^install anyway$/i })) + fireEvent.click(await screen.findByRole('button', { name: /^install$/i })) const dialog = await screen.findByRole('dialog', { name: /outside its supported trek versions/i }) fireEvent.click(within(dialog).getByRole('button', { name: /cancel/i })) diff --git a/client/src/components/Admin/AdminPluginsPanel.tsx b/client/src/components/Admin/AdminPluginsPanel.tsx index d18271294..9b2d1644b 100644 --- a/client/src/components/Admin/AdminPluginsPanel.tsx +++ b/client/src/components/Admin/AdminPluginsPanel.tsx @@ -1,10 +1,9 @@ -import { useEffect, useRef, useState, type CSSProperties, type ReactNode } from 'react' -import { createPortal } from 'react-dom' +import { useState, type ReactNode } from 'react' import { Blocks, AlertTriangle, RefreshCw, Trash2, Download, Bug, ShieldCheck, UploadCloud, - ArrowUpCircle, Github, ExternalLink, ChevronDown, Check, Lock, Search, Link2, KeyRound, ShieldAlert, - SlidersHorizontal, ArrowUpDown, CircleDot, MoreHorizontal, RotateCw, Database, - Globe, Info, History, PauseCircle, Puzzle, Bot, Loader2, + ArrowUpCircle, Github, ExternalLink, Check, Search, Link2, KeyRound, ShieldAlert, + SlidersHorizontal, CircleDot, Database, + Globe, History, Puzzle, Bot, Loader2, } from 'lucide-react' import PluginIcon from '../shared/PluginIcon' import { useInstanceSettings } from './useInstanceSettings' @@ -23,15 +22,17 @@ import { SettingsCard, SettingRows, SettingRow, SettingsHint, StatusPill, SETTINGS_BUTTON_PRIMARY, SETTINGS_ICON_BUTTON, } from '../Settings/settingsKit' -import { POPOVER } from '../Packing/packingPopoverStyles' import { - KNOWN_TYPES, PERM_KEYS, blockIsCurrent, dependencyRows, deriveDeps, fingerprint, formatCompactCount, - installOffer, isRegistrySourced, parseJson, signatureBlockView, sortLabel, statusLabel, - type PluginDep, type PluginRow, type RegistryItem, type SigSubject, type SortKey, - type StatusFilter, type T, type TypeFilter, type VersionInfo, type VersionMismatch, + KNOWN_TYPES, PERM_KEYS, blockIsCurrent, dependencyRows, fingerprint, formatCompactCount, + installOffer, isRegistrySourced, signatureBlockView, + type PluginDep, type PluginRow, type RegistryItem, type SigSubject, + type T, type VersionInfo, type VersionMismatch, } from './plugins/pluginModel' import { usePluginDetail } from './plugins/usePluginDetail' import { usePluginsAdmin } from './plugins/usePluginsAdmin' +import PluginFilterMenu from './plugins/PluginFilterMenu' +import PluginInstalledRow from './plugins/PluginInstalledRow' +import PluginSecurityDialog from './plugins/PluginSecurityDialog' /** * Admin → Plugins (#plugins). A full plugin-management surface: a segmented @@ -42,31 +43,13 @@ import { usePluginsAdmin } from './plugins/usePluginsAdmin' * tile; the security section at the bottom explains the model honestly. */ -// Runtime health → dot colour on the icon tile. -const HEALTH: Record = { - active: 'bg-success', - starting: 'bg-info animate-pulse', - error: 'bg-danger', - inactive: 'bg-content-faint/60', - disabled: 'bg-warning', - incompatible: 'bg-warning', -} - /** The head band of a dialog that warns before something happens: a faint wash of the warning tone. */ const WARN_TINT = 'color-mix(in srgb, var(--warning) 10%, transparent)' /** The eyebrow over a block, the size the dialogs' sections use. */ const EYEBROW = 'm-0 font-geist font-bold uppercase tracking-[.08em] text-content-faint' -/** A capability or dependency chip on an installed row. */ -const CHIP = 'inline-flex max-w-full items-center gap-1.5 rounded-full border px-2 py-[2px] font-medium' -const CHIP_NEUTRAL = 'border-edge-faint bg-surface-secondary text-content-secondary' -const CHIP_NET = 'border-transparent bg-info-soft text-info' -const CHIP_WARN = 'border-transparent bg-warning-soft text-warning' /** A small action inside a row or a card: the kit's button, a size down. */ const SMALL_BUTTON = 'inline-flex flex-none items-center justify-center gap-1.5 rounded-[10px] bg-surface-card px-2.5 py-1.5 font-semibold text-content shadow-sm ring-1 ring-edge-faint hover:bg-surface-secondary disabled:cursor-default disabled:opacity-50' const SMALL_PRIMARY = 'inline-flex flex-none items-center justify-center gap-1.5 rounded-[10px] bg-accent px-3 py-1.5 font-semibold text-accent-text hover:opacity-90 disabled:cursor-default disabled:bg-surface-tertiary disabled:text-content-faint' -/** A line of state under a row's name: a refused update, a paused one, an error. */ -const ROW_NOTE = 'mt-1.5 flex min-w-0 items-center gap-1.5' - /** A pill that explains itself on hover. */ function HintPill({ hint, children }: { hint: string; children: ReactNode }) { return ( @@ -76,19 +59,6 @@ function HintPill({ hint, children }: { hint: string; children: ReactNode }) { ) } -function ReviewedBadge({ t }: { t: T }) { - return -} - -/** Marks a manually-uploaded (sideloaded) plugin: no registry, unsigned, not reviewed. */ -function SideloadedBadge({ t }: { t: T }) { - return ( - - }>{t('admin.plugins.sideloaded')} - - ) -} - /** * Signed / Unsigned, for registry plugins only (see isRegistrySourced). * @@ -113,15 +83,6 @@ function TrustBadge({ signed, t }: { signed: boolean; t: T }) { ) } -/** Marks a dev-linked plugin: loaded from a local build dir + hot-reloaded (dev only). */ -function DevLinkBadge({ t }: { t: T }) { - return ( - - }>{t('admin.plugins.devLinkBadge')} - - ) -} - function TypeBadge({ type, t }: { type: string; t: T }) { return ( @@ -145,6 +106,7 @@ export default function AdminPluginsPanel() { sort, statusFilter, toggle, typeFilter, uninstallConfirmed, updatable, updateAll, updateAvailable, uploadInputRef, uploadPlugin, versionPicker, view, } = usePluginsAdmin() + const [securityOpen, setSecurityOpen] = useState(false) return (
+ setSecurityOpen(true)}> + + @@ -242,31 +207,9 @@ export default function AdminPluginsPanel() { /> - } - options={[ - ['all', t('admin.plugins.allTypes')], ['widget', t('admin.plugins.type.widget')], - ['integration', t('admin.plugins.type.integration')], ['page', t('admin.plugins.type.page')], - ['trip-page', t('admin.plugins.type.trip-page')], - ]} - valueLabel={typeFilter === 'all' ? t('admin.plugins.allTypes') : t(`admin.plugins.type.${typeFilter}` as never)} - onPick={v => setTypeFilter(v as TypeFilter)} /> - - {view === 'installed' && ( - } - options={[ - ['all', t('admin.plugins.allStatuses')], ['on', t('admin.plugins.status.active')], ['off', t('admin.plugins.stateOff')], - ['update', t('admin.plugins.filterUpdate')], ['err', t('admin.plugins.status.error')], - ]} - valueLabel={statusLabel(statusFilter, t)} - onPick={v => setStatusFilter(v as StatusFilter)} /> - )} - - } - options={view === 'discover' - ? [['name', t('admin.plugins.sortName')], ['recent', t('admin.plugins.sortRecent')], ['downloads', t('admin.plugins.sortDownloads')]] - : [['name', t('admin.plugins.sortName')], ['recent', t('admin.plugins.sortRecent')], ['updates', t('admin.plugins.sortUpdates')]]} - valueLabel={sortLabel(sort, t)} - onPick={v => setSort(v as SortKey)} /> +
{view === 'discover' ? ( @@ -299,7 +242,7 @@ export default function AdminPluginsPanel() { ) : ( {shownInstalled.map(p => ( - - + {securityOpen && setSecurityOpen(false)} />} {/* Registry detail dialog */} {detailFor && ( @@ -438,268 +381,6 @@ function SegBtn({ active, onClick, label, count }: { active: boolean; onClick: ( ) } -/** Re-renders while the page moves under an open, portaled menu, so it stays pinned to its trigger. */ -function useFollowTrigger(open: boolean) { - const [, reanchor] = useState(0) - useEffect(() => { - if (!open) return - const onMove = () => reanchor(n => n + 1) - window.addEventListener('scroll', onMove, true) - window.addEventListener('resize', onMove) - return () => { - window.removeEventListener('scroll', onMove, true) - window.removeEventListener('resize', onMove) - } - }, [open]) -} - -function FilterMenu({ id, label, valueLabel, options, onPick, value, menu, setMenu, icon }: { - id: string; label: string; valueLabel: string - options: Array<[string, string]>; onPick: (v: string) => void; value: string - menu: string | null; setMenu: (v: string | null) => void; icon?: ReactNode -}) { - const open = menu === id - const active = value !== options[0]?.[0] - const btnRef = useRef(null) - // Portaled like the row menu: the card around the toolbar clips what overflows it. - useFollowTrigger(open) - return ( -
- - {open && createPortal( -
- {options.map(([v, lbl]) => ( - - ))} -
, - document.body, - )} -
- ) -} - -function InstalledRow({ p, t, busy, menu, setMenu, hasUpdate, latestVer, newerIncompatible, blocked, onToggle, onUpdate, onRestart, onChangeVersion, onResume, onReviewBlock, onErrors, onEgress, onSettings, onUninstall }: { - p: PluginRow; t: T; busy: string | null; menu: string | null; setMenu: (v: string | null) => void - hasUpdate: boolean; latestVer?: string; newerIncompatible: { version: string; range: string } | null; blocked: boolean - onToggle: () => void; onUpdate: () => void; onRestart: () => void; onChangeVersion: () => void; onResume: () => void; onReviewBlock: () => void - onErrors: () => void; onEgress: () => void; onSettings: () => void; onUninstall: () => void -}) { - const caps = deriveCaps(parseJson(p.permissions, []), parseJson<{ widget?: { slot?: string } }>(p.capabilities, {}), t) - const deps = deriveDeps(p, t) - const menuOpen = menu === `row:${p.id}` - const menuBtnRef = useRef(null) - const working = busy === p.id - const on = p.enabled === 1 && p.status !== 'error' - - // The menu is portaled to and positioned `fixed` against the ⋯ button, - // because the row's ancestor (PageSidebar) is `overflow-hidden` and would clip - // an in-flow `absolute` menu — losing Delete on the lower rows of a long list. - // Re-anchor while the page moves under it. - useFollowTrigger(menuOpen) - - const hostCount = p.egressHostCount ?? 0 - - return ( -
-
- - - - - - -
- -
-
- {p.name} - {p.version && v{p.version}} - {p.reviewed_at && } - {p.source_repo === 'local:upload' && } - {p.source_repo === 'local:link' && } - {/* Registry plugins only — a sideloaded/dev-linked plugin already says something - strictly stronger, and stacking "Unsigned" on top of it just dilutes the amber. */} - {isRegistrySourced(p.source_repo) && } -
- {p.description &&

{p.description}

} - {/* A refused update leaves a WORKING plugin pinned at its old version — so this is - its own row state, not an error state, and it persists until it is resolved - rather than dying with the toast that first reported it. */} - {blocked && p.updateBlock && ( -
- - {t('admin.plugins.updateBlocked', { reason: p.updateBlock.detail ?? p.updateBlock.code })} - -
- )} - {/* A newer version this TREK can't run — informational, never a button: the fix - is a TREK upgrade, so the row must not nag or offer a doomed install. */} - {newerIncompatible && ( -
- - {t('admin.plugins.newerNeedsTrek', { version: newerIncompatible.version, range: newerIncompatible.range })} -
- )} - {/* Held: the admin deliberately installed a non-latest version, so updates are - paused rather than nagged — resuming is one click, right where the pause shows. */} - {p.updateHold && ( -
- - {t('admin.plugins.updatesHeld', { version: p.version ?? '' })} - -
- )} - {p.status === 'error' && p.last_error ? ( -
- {p.last_error} -
- ) : (caps.length > 0 || p.operatorEgress) && ( -
- {caps.map((c, i) => ( - - {c.label} - - ))} - {/* This plugin talks to a service only the OPERATOR can name (a self-hosted - Gotify/ntfy), so its manifest can't list the host — the admin adds it. - Actionable, and warning-toned until at least one host exists, because - until then the plugin cannot reach anything and looks silently broken. */} - {p.operatorEgress && ( - - - - )} -
- )} - {deps.length > 0 && ( -
- {deps.map((d, i) => ( - - {d.label} - - ))} -
- )} -
- -
- {hasUpdate && ( - - )} - - {p.enabled === 1 ? t('admin.plugins.status.active') : t('admin.plugins.stateOff')} - - -
- - - - {menuOpen && createPortal( -
- {p.enabled === 1 && ( - } label={t('admin.plugins.restart')} onClick={onRestart} /> - )} - {/* A plugin gets the item if it declares scope:'instance' fields OR actions — - an action-only plugin still needs the dialog to run its buttons. */} - {((p.instanceSettingsCount ?? 0) > 0 || (p.instanceActionsCount ?? 0) > 0) && ( - } label={t('admin.plugins.instanceSettings')} onClick={onSettings} /> - )} - } label={t('admin.plugins.viewErrors')} onClick={onErrors} /> - {/* Only a plugin that DECLARED operatorEgress gets the item — an admin must - never be invited to widen egress for a plugin that didn't ask for it - (same rule the row's egress chip follows). */} - {p.operatorEgress && ( - } label={t('admin.plugins.allowedHosts')} onClick={onEgress} /> - )} - {/* Registry plugins only — a sideload/dev-link has no registry versions to pick from. */} - {isRegistrySourced(p.source_repo) && ( - } label={t('admin.plugins.changeVersion')} onClick={onChangeVersion} /> - )} - {p.source_repo && p.source_repo !== 'local:upload' && p.source_repo !== 'local:link' && ( - <> - setMenu(null)} - className={`${MENU_ITEM} text-content-secondary`} style={fs(12.5, 'body')}> - {t('admin.plugins.sourceRepo')} - - setMenu(null)} - className={`${MENU_ITEM} text-content-secondary`} style={fs(12.5, 'body')}> - {t('admin.plugins.reportIssue')} - - - )} -
- } label={t('common.delete')} danger onClick={onUninstall} /> -
, - document.body, - )} -
-
-
- ) -} - -/** Tallest the ⋯ menu gets (6 items + divider + padding). Drives the flip. */ -const ROW_MENU_MAX_H = 260 - -/** Pin a portaled menu to the right edge of its trigger, flipping up when the bottom is tight. */ -function anchorMenu(trigger: HTMLElement | null): CSSProperties { - const r = trigger?.getBoundingClientRect() - if (!r) return { position: 'fixed', top: 0, right: 0, zIndex: 100 } - const spaceBelow = window.innerHeight - r.bottom - const openUp = spaceBelow < ROW_MENU_MAX_H && r.top > spaceBelow - return { - position: 'fixed', - right: Math.max(8, window.innerWidth - r.right), - ...(openUp ? { bottom: window.innerHeight - r.top + 4 } : { top: r.bottom + 4 }), - zIndex: 100, - } -} - -/** One entry of a popover menu, in the packing popovers' shape. */ -const MENU_ITEM = 'flex w-full items-center gap-2.5 rounded-[9px] px-2.5 py-2 font-medium transition-colors hover:bg-surface-tertiary' - -function MenuItem({ icon, label, onClick, danger }: { icon: ReactNode; label: string; onClick: () => void; danger?: boolean }) { - return ( - - ) -} - function Screenshot({ url, className, iconSize = 28 }: { url: string | null; className: string; iconSize?: number }) { const [failed, setFailed] = useState(false) return ( @@ -751,6 +432,16 @@ function RegistryGrid({ items, onInstall, onOpenDetail, busy, t, installedIds, f }>{t('admin.plugins.reviewed')} )} + {/* Says up front that this one has not been confirmed for the TREK it would run on, + so "Install" never reads as a plain install; the dialog explains the risk. */} + {!installed && offer.warn && ( + // Solid, not the soft pill "Reviewed" uses: it has to stand out on any screenshot. + + + {t('admin.plugins.untestedChip', { host: offer.warn.hostVersion })} + + )} @@ -829,8 +520,8 @@ function Tile({ children, warn }: { children: ReactNode; warn?: boolean }) { * The TREK_PLUGINS_IGNORE_TREK_RANGE warning (copy and modes from useRangeBypass). Its own * component rather than ConfirmDialog because the notice mode has no cancel button. */ -function RangeBypassDialog({ copy: { title, body, confirm }, onConfirm, onClose, t }: { - copy: { title: string; body: string; confirm: boolean }; onConfirm: () => void; onClose: () => void; t: T +function RangeBypassDialog({ copy: { title, body, confirm, confirmLabel }, onConfirm, onClose, t }: { + copy: { title: string; body: string; confirm: boolean; confirmLabel: string }; onConfirm: () => void; onClose: () => void; t: T }) { return ( {t('common.cancel')} - {t('admin.plugins.installAnyway')} + {confirmLabel} ) : ( {t('common.ok')} @@ -1495,45 +1186,3 @@ function DependencyResolveDialog({ data, t, busy, installedIds, onDownload, onCl ) } - -// Footer: a plain-language note on what "Reviewed" means, plus a collapsible -// panel that lays out how plugins are contained, the limits, and the worst case. -function SecurityInfo({ t }: { t: T }) { - const [open, setOpen] = useState(false) - const sections: Array<[string, string]> = [ - ['admin.plugins.security.isolationTitle', 'admin.plugins.security.isolationBody'], - ['admin.plugins.security.permsTitle', 'admin.plugins.security.permsBody'], - ['admin.plugins.security.limitsTitle', 'admin.plugins.security.limitsBody'], - ['admin.plugins.security.worstTitle', 'admin.plugins.security.worstBody'], - ['admin.plugins.security.reviewedTitle', 'admin.plugins.security.reviewedBody'], - ['admin.plugins.security.signedTitle', 'admin.plugins.security.signedBody'], - ['admin.plugins.security.trustTitle', 'admin.plugins.security.trustBody'], - ] - return ( - setOpen(o => !o)} aria-expanded={open} - className="inline-flex max-w-full items-center gap-1.5 text-start font-bold text-content hover:opacity-80"> - {t('admin.plugins.security.title')} - - - )} - > -
- - {t('admin.plugins.reviewedMeaning')} -
- {open && ( -
- {sections.map(([h, b]) => ( -
-

{t(h as never)}

-

{t(b as never)}

-
- ))} -
- )} -
- ) -} diff --git a/client/src/components/Admin/plugins/PluginFilterMenu.tsx b/client/src/components/Admin/plugins/PluginFilterMenu.tsx new file mode 100644 index 000000000..531907f1b --- /dev/null +++ b/client/src/components/Admin/plugins/PluginFilterMenu.tsx @@ -0,0 +1,167 @@ +import { Check, ChevronDown, SlidersHorizontal } from 'lucide-react'; +import { useRef } from 'react'; +import { createPortal } from 'react-dom'; +import { POPOVER } from '../../Packing/packingPopoverStyles'; +import { fs } from '../../shared/DialogShell'; +import { anchorMenu, MENU_ITEM, useFollowTrigger } from './pluginMenu'; +import { sortLabel, type SortKey, type StatusFilter, type T, type TypeFilter } from './pluginModel'; + +const MENU_ID = 'filters'; + +interface Section { + title: string; + value: string; + options: Array<[string, string]>; + onPick: (v: string) => void; +} + +/** + * Type, status and order of the plugin list behind one button instead of three, so the + * toolbar stays search plus one control. The button counts the filters that narrow the + * list (the order does not narrow it) and names the order it is in. + */ +export default function PluginFilterMenu({ + t, + view, + typeFilter, + setTypeFilter, + statusFilter, + setStatusFilter, + sort, + setSort, + menu, + setMenu, +}: { + t: T; + view: 'installed' | 'discover'; + typeFilter: TypeFilter; + setTypeFilter: (v: TypeFilter) => void; + statusFilter: StatusFilter; + setStatusFilter: (v: StatusFilter) => void; + sort: SortKey; + setSort: (v: SortKey) => void; + menu: string | null; + setMenu: (v: string | null) => void; +}) { + const open = menu === MENU_ID; + const btnRef = useRef(null); + useFollowTrigger(open); + + const sections: Section[] = [ + { + title: t('admin.plugins.filterType'), + value: typeFilter, + options: [ + ['all', t('admin.plugins.allTypes')], + ['widget', t('admin.plugins.type.widget')], + ['integration', t('admin.plugins.type.integration')], + ['page', t('admin.plugins.type.page')], + ['trip-page', t('admin.plugins.type.trip-page')], + ], + onPick: (v) => setTypeFilter(v as TypeFilter), + }, + ...(view === 'installed' + ? [ + { + title: t('admin.plugins.filterStatus'), + value: statusFilter, + options: [ + ['all', t('admin.plugins.allStatuses')], + ['on', t('admin.plugins.status.active')], + ['off', t('admin.plugins.stateOff')], + ['update', t('admin.plugins.filterUpdate')], + ['err', t('admin.plugins.status.error')], + ] as Array<[string, string]>, + onPick: (v: string) => setStatusFilter(v as StatusFilter), + }, + ] + : []), + { + title: t('admin.plugins.sortBy'), + value: sort, + options: + view === 'discover' + ? [ + ['name', t('admin.plugins.sortName')], + ['recent', t('admin.plugins.sortRecent')], + ['downloads', t('admin.plugins.sortDownloads')], + ] + : [ + ['name', t('admin.plugins.sortName')], + ['recent', t('admin.plugins.sortRecent')], + ['updates', t('admin.plugins.sortUpdates')], + ], + onPick: (v) => setSort(v as SortKey), + }, + ]; + const narrowing = (typeFilter !== 'all' ? 1 : 0) + (view === 'installed' && statusFilter !== 'all' ? 1 : 0); + + return ( +
+ + {open && + createPortal( +
+ {sections.map((s, i) => ( +
0 ? 'mt-1.5 border-t border-edge-faint pt-1.5' : undefined}> +

+ {s.title} +

+ {s.options.map(([v, label]) => ( + + ))} +
+ ))} +
, + document.body + )} +
+ ); +} diff --git a/client/src/components/Admin/plugins/PluginInstalledRow.tsx b/client/src/components/Admin/plugins/PluginInstalledRow.tsx new file mode 100644 index 000000000..7404e1363 --- /dev/null +++ b/client/src/components/Admin/plugins/PluginInstalledRow.tsx @@ -0,0 +1,426 @@ +import { + AlertTriangle, + ArrowUpCircle, + Bug, + CircleDot, + Github, + Globe, + History, + Info, + KeyRound, + Link2, + MoreHorizontal, + PauseCircle, + RotateCw, + ShieldAlert, + ShieldCheck, + SlidersHorizontal, + Trash2, + UploadCloud, +} from 'lucide-react'; +import { useRef, type ComponentType, type ReactNode } from 'react'; +import { createPortal } from 'react-dom'; +import { POPOVER } from '../../Packing/packingPopoverStyles'; +import ToggleSwitch from '../../Settings/ToggleSwitch'; +import { fs } from '../../shared/DialogShell'; +import PluginIcon from '../../shared/PluginIcon'; +import { Tooltip } from '../../shared/Tooltip'; +import { deriveCaps } from '../pluginCaps'; +import { anchorMenu, MENU_ITEM, useFollowTrigger } from './pluginMenu'; +import { deriveDeps, isRegistrySourced, parseJson, type PluginRow, type T } from './pluginModel'; + +// Runtime health → dot colour on the icon tile. +const HEALTH: Record = { + active: 'bg-success', + starting: 'bg-info animate-pulse', + error: 'bg-danger', + inactive: 'bg-content-faint/60', + disabled: 'bg-warning', + incompatible: 'bg-warning', +}; + +/** Every fact about a plugin wears the same chip; only its icon carries a colour. */ +const FACT = + 'inline-flex max-w-full items-center gap-1.5 rounded-full border border-edge-faint bg-surface-secondary px-2 py-[2px] font-medium text-content-secondary'; + +type Tone = 'warning' | 'danger' | 'neutral'; +const NOTICE_LOOK: Record = { + warning: 'bg-warning-soft text-warning', + danger: 'bg-danger-soft text-danger', + neutral: 'bg-surface-secondary text-content-muted', +}; + +/** A state of the plugin worth reading on its own line: what it means, and the one action that fixes it. */ +interface Notice { + key: string; + tone: Tone; + icon: ComponentType<{ size?: number; className?: string }>; + text: string; + action?: { label: string; onClick: () => void; disabled?: boolean }; +} + +function Fact({ + icon, + iconClass = 'text-content-muted', + hint, + children, +}: { + icon: ReactNode; + iconClass?: string; + hint?: string; + children: ReactNode; +}) { + const chip = ( + + {icon} + {children} + + ); + return hint ? ( + + {chip} + + ) : ( + chip + ); +} + +function NoticeLine({ n }: { n: Notice }) { + return ( +
+ + {n.text} + {n.action && ( + + )} +
+ ); +} + +function MenuItem({ + icon, + label, + onClick, + danger, +}: { + icon: ReactNode; + label: string; + onClick: () => void; + danger?: boolean; +}) { + return ( + + ); +} + +interface InstalledRowProps { + p: PluginRow; + t: T; + busy: string | null; + menu: string | null; + setMenu: (v: string | null) => void; + hasUpdate: boolean; + latestVer?: string; + newerIncompatible: { version: string; range: string } | null; + blocked: boolean; + onToggle: () => void; + onUpdate: () => void; + onRestart: () => void; + onChangeVersion: () => void; + onResume: () => void; + onReviewBlock: () => void; + onErrors: () => void; + onEgress: () => void; + onSettings: () => void; + onUninstall: () => void; +} + +/** + * One installed plugin, read top to bottom: its name and version, what it does, the + * facts about it as one row of alike chips (what it adds, whether it was reviewed and + * signed, where it may connect), then each state that needs attention on a line of its + * own with the action that resolves it. The controls stay on the right. + */ +export default function PluginInstalledRow(props: InstalledRowProps) { + const { p, t, busy, menu, setMenu, hasUpdate, latestVer, newerIncompatible, blocked } = props; + const caps = deriveCaps( + parseJson(p.permissions, []), + parseJson<{ widget?: { slot?: string } }>(p.capabilities, {}), + t + ); + const deps = deriveDeps(p, t); + const menuOpen = menu === `row:${p.id}`; + const menuBtnRef = useRef(null); + const working = busy === p.id; + const hostCount = p.egressHostCount ?? 0; + + // The menu is portaled to and positioned `fixed` against the ⋯ button, + // because the row's ancestor (PageSidebar) is `overflow-hidden` and would clip + // an in-flow `absolute` menu — losing Delete on the lower rows of a long list. + useFollowTrigger(menuOpen); + + const notices: Notice[] = []; + if (p.status === 'error' && p.last_error) { + notices.push({ key: 'error', tone: 'danger', icon: AlertTriangle, text: p.last_error }); + } + // A refused update leaves a WORKING plugin pinned at its old version: its own state, + // kept until resolved rather than dying with the toast that first reported it. + if (blocked && p.updateBlock) { + notices.push({ + key: 'block', + tone: 'warning', + icon: ShieldAlert, + text: t('admin.plugins.updateBlocked', { reason: p.updateBlock.detail ?? p.updateBlock.code }), + action: { label: t('admin.plugins.reviewBlock'), onClick: props.onReviewBlock }, + }); + } + for (const [i, d] of deps.entries()) { + if (d.blocked || d.warn) notices.push({ key: `dep${i}`, tone: 'warning', icon: d.icon, text: d.label }); + } + // Held: the admin deliberately installed a non-latest version, so updates are paused + // rather than nagged — resuming is one click, right where the pause shows. + if (p.updateHold) { + notices.push({ + key: 'hold', + tone: 'neutral', + icon: PauseCircle, + text: t('admin.plugins.updatesHeld', { version: p.version ?? '' }), + action: { label: t('admin.plugins.resumeUpdates'), onClick: props.onResume, disabled: working }, + }); + } + // A newer version this TREK can't run: informational, never a button, the fix is a TREK upgrade. + if (newerIncompatible) { + notices.push({ + key: 'newer', + tone: 'neutral', + icon: Info, + text: t('admin.plugins.newerNeedsTrek', { version: newerIncompatible.version, range: newerIncompatible.range }), + }); + } + + return ( +
+
+ + + + + + +
+ +
+
+ + {p.name} + + {p.version && ( + + v{p.version} + + )} +
+ {p.description && ( +

+ {p.description} +

+ )} + +
+ {caps.map((c, i) => ( + } iconClass={c.net ? 'text-info' : 'text-content-muted'}> + {c.label} + + ))} + {deps + .filter((d) => !d.blocked && !d.warn) + .map((d, i) => ( + }> + {d.label} + + ))} + {p.reviewed_at && ( + } iconClass="text-success"> + {t('admin.plugins.reviewed')} + + )} + {p.source_repo === 'local:upload' && ( + } iconClass="text-warning" hint={t('admin.plugins.sideloadedHint')}> + {t('admin.plugins.sideloaded')} + + )} + {p.source_repo === 'local:link' && ( + } hint={t('admin.plugins.devLinkHint')}> + {t('admin.plugins.devLinkBadge')} + + )} + {/* Registry plugins only: a sideloaded or dev-linked plugin already says something stronger. */} + {isRegistrySourced(p.source_repo) && + (p.signed ? ( + } hint={t('admin.plugins.signedHint')}> + {t('admin.plugins.signed')} + + ) : ( + } iconClass="text-warning" hint={t('admin.plugins.unsignedHint')}> + {t('admin.plugins.unsigned')} + + ))} + {/* A service only the operator can name (a self-hosted Gotify/ntfy): the admin adds + its host, and until one exists the plugin reaches nothing, so it asks for it. */} + {p.operatorEgress && ( + + + + )} +
+ + {notices.length > 0 && ( +
+ {notices.map((n) => ( + + ))} +
+ )} +
+ +
+ {hasUpdate && ( + + )} + + {p.enabled === 1 ? t('admin.plugins.status.active') : t('admin.plugins.stateOff')} + + +
+ + + + {menuOpen && + createPortal( +
+ {p.enabled === 1 && ( + } + label={t('admin.plugins.restart')} + onClick={props.onRestart} + /> + )} + {/* Instance fields OR actions: an action-only plugin still needs the dialog to run its buttons. */} + {((p.instanceSettingsCount ?? 0) > 0 || (p.instanceActionsCount ?? 0) > 0) && ( + } + label={t('admin.plugins.instanceSettings')} + onClick={props.onSettings} + /> + )} + } label={t('admin.plugins.viewErrors')} onClick={props.onErrors} /> + {/* Only a plugin that DECLARED operatorEgress: an admin is never invited to widen egress for one that did not ask. */} + {p.operatorEgress && ( + } + label={t('admin.plugins.allowedHosts')} + onClick={props.onEgress} + /> + )} + {/* Registry plugins only — a sideload/dev-link has no registry versions to pick from. */} + {isRegistrySourced(p.source_repo) && ( + } + label={t('admin.plugins.changeVersion')} + onClick={props.onChangeVersion} + /> + )} + {p.source_repo && p.source_repo !== 'local:upload' && p.source_repo !== 'local:link' && ( + <> + setMenu(null)} + className={`${MENU_ITEM} text-content-secondary`} + style={fs(12.5, 'body')} + > + + + {' '} + {t('admin.plugins.sourceRepo')} + + setMenu(null)} + className={`${MENU_ITEM} text-content-secondary`} + style={fs(12.5, 'body')} + > + + + {' '} + {t('admin.plugins.reportIssue')} + + + )} +
+ } label={t('common.delete')} danger onClick={props.onUninstall} /> +
, + document.body + )} +
+
+
+ ); +} diff --git a/client/src/components/Admin/plugins/PluginSecurityDialog.tsx b/client/src/components/Admin/plugins/PluginSecurityDialog.tsx new file mode 100644 index 000000000..08fe76901 --- /dev/null +++ b/client/src/components/Admin/plugins/PluginSecurityDialog.tsx @@ -0,0 +1,72 @@ +import { ShieldCheck } from 'lucide-react'; +import { + DialogButton, + DialogFooter, + DialogHeader, + DialogShell, + DialogTile, + FooterSpacer, + NEUTRAL_TINT, + fs, +} from '../../shared/DialogShell'; +import type { T } from './pluginModel'; + +/** + * How plugins are contained and where that ends, opened from the shield in the panel's + * header. It used to sit under the list as a card of its own, which pushed a wall of + * text into the page every admin scrolls past. + */ +export default function PluginSecurityDialog({ t, onClose }: { t: T; onClose: () => void }) { + const sections: Array<[string, string]> = [ + [t('admin.plugins.security.isolationTitle'), t('admin.plugins.security.isolationBody')], + [t('admin.plugins.security.permsTitle'), t('admin.plugins.security.permsBody')], + [t('admin.plugins.security.limitsTitle'), t('admin.plugins.security.limitsBody')], + [t('admin.plugins.security.worstTitle'), t('admin.plugins.security.worstBody')], + [t('admin.plugins.security.reviewedTitle'), t('admin.plugins.security.reviewedBody')], + [t('admin.plugins.security.signedTitle'), t('admin.plugins.security.signedBody')], + [t('admin.plugins.security.trustTitle'), t('admin.plugins.security.trustBody')], + ]; + return ( + + + + } + tint={NEUTRAL_TINT} + labelId="plugin-security-title" + title={t('admin.plugins.security.short')} + sub={t('admin.plugins.reviewedMeaning')} + subWraps + onClose={onClose} + /> + } + footer={ + + + + {t('common.ok')} + + + } + > +
+ {sections.map(([h, b]) => ( +
+

+ {h} +

+

+ {b} +

+
+ ))} +
+
+ ); +} diff --git a/client/src/components/Admin/plugins/pluginMenu.ts b/client/src/components/Admin/plugins/pluginMenu.ts new file mode 100644 index 000000000..a2490b764 --- /dev/null +++ b/client/src/components/Admin/plugins/pluginMenu.ts @@ -0,0 +1,41 @@ +import { useEffect, useState, type CSSProperties } from 'react'; + +/** The tallest a row's ⋯ menu gets; below that it opens upwards instead. */ +const ROW_MENU_MAX_H = 260; + +/** + * Where a portaled menu sits: right-aligned under its trigger, or above it when the + * window has no room below. Fixed, because the cards around the toolbar and the rows + * clip whatever overflows them. + */ +export function anchorMenu(trigger: HTMLElement | null): CSSProperties { + const r = trigger?.getBoundingClientRect(); + if (!r) return { position: 'fixed', top: 0, right: 0, zIndex: 100 }; + const spaceBelow = window.innerHeight - r.bottom; + const openUp = spaceBelow < ROW_MENU_MAX_H && r.top > spaceBelow; + return { + position: 'fixed', + right: Math.max(8, window.innerWidth - r.right), + ...(openUp ? { bottom: window.innerHeight - r.top + 4 } : { top: r.bottom + 4 }), + zIndex: 100, + }; +} + +/** One entry of a popover menu, in the packing popovers' shape. */ +export const MENU_ITEM = + 'flex w-full items-center gap-2.5 rounded-[9px] px-2.5 py-2 font-medium transition-colors hover:bg-surface-tertiary'; + +/** Re-renders while the page moves under an open, portaled menu, so it stays pinned to its trigger. */ +export function useFollowTrigger(open: boolean) { + const [, reanchor] = useState(0); + useEffect(() => { + if (!open) return; + const onMove = () => reanchor((n) => n + 1); + window.addEventListener('scroll', onMove, true); + window.addEventListener('resize', onMove); + return () => { + window.removeEventListener('scroll', onMove, true); + window.removeEventListener('resize', onMove); + }; + }, [open]); +} diff --git a/client/src/components/Admin/plugins/pluginModel.test.ts b/client/src/components/Admin/plugins/pluginModel.test.ts index aa9b27f20..bc685d1bd 100644 --- a/client/src/components/Admin/plugins/pluginModel.test.ts +++ b/client/src/components/Admin/plugins/pluginModel.test.ts @@ -111,8 +111,14 @@ describe('pluginModel', () => { blocked: false, version: '0.9.0', }); - expect(installOffer(incompatible, t)).toMatchObject({ blocked: true, label: 'admin.plugins.incompatible' }); - expect(installOffer(incompatible, t, true)).toMatchObject({ blocked: false, label: 'admin.plugins.installAnyway' }); + // Nothing fits this TREK: still an Install, behind the admin's own warning for this plugin. + expect(installOffer(incompatible, t)).toMatchObject({ + blocked: false, + label: 'admin.plugins.install', + warn: { acceptedByAdmin: true, trekRange: '>=9', hostVersion: '4.0.0' }, + }); + // TREK_PLUGINS_IGNORE_TREK_RANGE lets every plugin through, so the warning is not the admin's own choice. + expect(installOffer(incompatible, t, true)).toMatchObject({ blocked: false, warn: { acceptedByAdmin: false } }); }); it('FE-ADMIN-PLUGIN-MODEL-008: deriveDeps marks the blockers among the declared dependencies', () => { diff --git a/client/src/components/Admin/plugins/pluginModel.ts b/client/src/components/Admin/plugins/pluginModel.ts index 8ee820ebd..85dab42d7 100644 --- a/client/src/components/Admin/plugins/pluginModel.ts +++ b/client/src/components/Admin/plugins/pluginModel.ts @@ -298,7 +298,8 @@ export function installOffer( title, }; } - return { blocked: true, label: t('admin.plugins.incompatible'), title }; + // No version fits this TREK: the admin may still install this one plugin at their own risk. + return bypassOffer(item, t, title, true); } /** diff --git a/client/src/components/Admin/plugins/usePluginsAdmin.test.ts b/client/src/components/Admin/plugins/usePluginsAdmin.test.ts index 890434571..788deab8b 100644 --- a/client/src/components/Admin/plugins/usePluginsAdmin.test.ts +++ b/client/src/components/Admin/plugins/usePluginsAdmin.test.ts @@ -1,4 +1,4 @@ -// FE-ADMIN-PLUGINS-HOOK-001 to -012: the plugin admin logic behind both admin shells. +// FE-ADMIN-PLUGINS-HOOK-001 to -014: the plugin admin logic behind both admin shells. import { act, renderHook, waitFor } from '@testing-library/react'; import type { DragEvent } from 'react'; @@ -266,4 +266,36 @@ describe('usePluginsAdmin', () => { expect(toast.success).toHaveBeenCalledWith('admin.plugins.uninstalled'); expect(result.current.confirmUninstall).toBeNull(); }); + + it('FE-ADMIN-PLUGINS-HOOK-013: switching on a plugin outside its TREK range asks first, then runs it anyway', async () => { + serve([ + row({ enabled: 0, dependencyStatus: 'hostIncompatible', trekRange: '>=3.2.0 <4.0.0', hostVersion: '5.0.0' }), + ]); + vi.spyOn(adminApi, 'pluginActivate').mockResolvedValue({}); + const { result } = await mounted(); + act(() => result.current.toggle(result.current.plugins[0]!)); + expect(adminApi.pluginActivate).not.toHaveBeenCalled(); + expect(result.current.bypass.warning).toMatchObject({ + acceptedByAdmin: true, + action: 'activate', + trekRange: '>=3.2.0 <4.0.0', + }); + await act(async () => { + result.current.bypass.confirm(); + }); + await waitFor(() => expect(adminApi.pluginActivate).toHaveBeenCalledWith('alpha', false, true)); + }); + + it('FE-ADMIN-PLUGINS-HOOK-014: installing behind the admin warning sends their consent for this plugin', async () => { + serve([], [entry({ id: 'beta', name: 'Beta' })]); + vi.spyOn(adminApi, 'pluginInstall').mockResolvedValue({ id: 'beta', version: '1.0.0', trekRangeBypassed: null }); + const { result } = await mounted(); + const warn = { name: 'Beta', trekRange: '<5.0.0', hostVersion: '5.0.0', acceptedByAdmin: true, onConfirm: null }; + act(() => result.current.install('beta', undefined, warn)); + expect(adminApi.pluginInstall).not.toHaveBeenCalled(); + await act(async () => { + result.current.bypass.confirm(); + }); + await waitFor(() => expect(adminApi.pluginInstall).toHaveBeenCalledWith('beta', { acceptTrekRange: true })); + }); }); diff --git a/client/src/components/Admin/plugins/usePluginsAdmin.ts b/client/src/components/Admin/plugins/usePluginsAdmin.ts index a1717ce2e..db4e7a829 100644 --- a/client/src/components/Admin/plugins/usePluginsAdmin.ts +++ b/client/src/components/Admin/plugins/usePluginsAdmin.ts @@ -317,9 +317,14 @@ export function usePluginsAdmin() { act( id, () => - adminApi.pluginInstall(id, version ? { version } : undefined).then((r) => { - if (!warn) bypass.notice(id, r?.trekRangeBypassed); - }), + adminApi + .pluginInstall(id, { + ...(version ? { version } : {}), + ...(warn?.acceptedByAdmin ? { acceptTrekRange: true } : {}), + }) + .then((r) => { + if (!warn) bypass.notice(id, r?.trekRangeBypassed); + }), t('admin.plugins.installed') ) ); @@ -392,10 +397,10 @@ export function usePluginsAdmin() { } }; - const attemptActivate = (p: PluginRow) => { + const attemptActivate = (p: PluginRow, acceptTrekRange = false) => { const cascaded = autoEnabledDeps(p); return adminApi - .pluginActivate(p.id) + .pluginActivate(p.id, false, acceptTrekRange) .then(() => { toast.success(t('admin.plugins.activated')); if (cascaded.length) @@ -415,12 +420,29 @@ export function usePluginsAdmin() { void act(p.id, () => adminApi.pluginDeactivate(p.id), t('admin.plugins.deactivated')); return; } - setBusy(p.id); - setMenu(null); - attemptActivate(p).finally(() => { - setBusy(null); - refresh(); - }); + const run = (acceptTrekRange: boolean) => { + setBusy(p.id); + setMenu(null); + attemptActivate(p, acceptTrekRange).finally(() => { + setBusy(null); + refresh(); + }); + }; + // Outside the TREK versions its author declared: the admin may run it anyway, after + // reading what that risks. The choice holds for this version of the plugin only. + if (p.dependencyStatus === 'hostIncompatible') { + const warn: RangeWarning = { + name: p.name, + trekRange: p.trekRange ?? null, + hostVersion: p.hostVersion ?? '?', + acceptedByAdmin: true, + onConfirm: null, + action: 'activate', + }; + bypass.guard(warn, () => run(true)); + return; + } + run(false); }; // Download a missing/outdated plugin dependency (latest compatible for its range, diff --git a/client/src/components/Admin/useRangeBypass.test.ts b/client/src/components/Admin/useRangeBypass.test.ts index 979ffdad5..e173b92ab 100644 --- a/client/src/components/Admin/useRangeBypass.test.ts +++ b/client/src/components/Admin/useRangeBypass.test.ts @@ -16,15 +16,23 @@ describe('rangeBypass helpers', () => { blocked: false, warn: true, label: 'admin.plugins.dep.trekBypassed:{"range":">=3.2.0 <4.0.0","host":"4.0.0"}', }) expect(bypassChip({ trekRange: null, hostVersion: '4.0.0' }, t)).toMatchObject({ label: 'admin.plugins.dep.trekBypassedUnknown' }) + // An admin's own "run it anyway" for this one plugin says so, not "version checks off". + expect(bypassChip({ trekRange: '>=3.2.0 <4.0.0', hostVersion: '5.0.0', acceptedByAdmin: true }, t)).toMatchObject({ + warn: true, label: 'admin.plugins.dep.trekAccepted:{"range":">=3.2.0 <4.0.0","host":"5.0.0"}', + }) + expect(bypassChip({ trekRange: null, hostVersion: '5.0.0', acceptedByAdmin: true }, t)).toMatchObject({ + label: 'admin.plugins.dep.trekAcceptedUnknown', + }) }) - it('bypassOffer: an enabled "Install anyway" carrying what the dialog must say', () => { + it('bypassOffer: an enabled Install that carries what the dialog must say first', () => { const offer = bypassOffer({ name: 'Gotify', trek: '>=4.0.0', hostVersion: '3.3.0' }, t, 'why') expect(offer).toEqual({ - blocked: false, label: 'admin.plugins.installAnyway', title: 'why', - warn: { name: 'Gotify', trekRange: '>=4.0.0', hostVersion: '3.3.0', onConfirm: null }, + blocked: false, label: 'admin.plugins.install', title: 'why', + warn: { name: 'Gotify', trekRange: '>=4.0.0', hostVersion: '3.3.0', acceptedByAdmin: false, onConfirm: null }, }) expect(bypassOffer({ name: 'X' }, t, 'why').warn).toMatchObject({ trekRange: null, hostVersion: '?' }) + expect(bypassOffer({ name: 'X' }, t, 'why', true).warn).toMatchObject({ acceptedByAdmin: true }) }) it('rangeWarningCopy: confirm vs notice title, ranged vs rangeless body', () => { @@ -32,10 +40,25 @@ describe('rangeBypass helpers', () => { expect(rangeWarningCopy({ ...ranged, onConfirm: () => {} }, t)).toEqual({ confirm: true, title: 'admin.plugins.rangeBypass.title', body: 'admin.plugins.rangeBypass.body:{"name":"G","range":">=4.0.0","host":"3.3.0"}', + confirmLabel: 'admin.plugins.installAnyway', }) expect(rangeWarningCopy({ name: 'G', trekRange: null, hostVersion: '3.3.0', onConfirm: null }, t)).toEqual({ confirm: false, title: 'admin.plugins.rangeBypass.noticeTitle', - body: 'admin.plugins.rangeBypass.bodyUnknown:{"name":"G","host":"3.3.0"}', + body: 'admin.plugins.rangeBypass.bodyUnknown:{"name":"G","range":null,"host":"3.3.0"}', + confirmLabel: 'admin.plugins.installAnyway', + }) + }) + + it('rangeWarningCopy: an admin choosing for one plugin gets the short title, their own body and the right button', () => { + const mine = { name: 'G', trekRange: '>=4.0.0', hostVersion: '5.0.0', acceptedByAdmin: true, onConfirm: () => {} } + expect(rangeWarningCopy(mine, t)).toEqual({ + confirm: true, title: 'admin.plugins.rangeBypass.titleAdmin', + body: 'admin.plugins.rangeBypass.bodyAdmin:{"name":"G","range":">=4.0.0","host":"5.0.0"}', + confirmLabel: 'admin.plugins.installAnyway', + }) + expect(rangeWarningCopy({ ...mine, trekRange: null, action: 'activate' }, t)).toMatchObject({ + body: 'admin.plugins.rangeBypass.bodyAdminUnknown:{"name":"G","range":null,"host":"5.0.0"}', + confirmLabel: 'admin.plugins.activateAnyway', }) }) }) diff --git a/client/src/components/Admin/useRangeBypass.ts b/client/src/components/Admin/useRangeBypass.ts index 5a55a04ca..ddf8f92a4 100644 --- a/client/src/components/Admin/useRangeBypass.ts +++ b/client/src/components/Admin/useRangeBypass.ts @@ -4,10 +4,21 @@ import { useTranslation } from '../../i18n' type T = (k: string, p?: Record) => string -/** The server's "this only went through because TREK_PLUGINS_IGNORE_TREK_RANGE is set" marker. */ -export interface TrekRangeBypass { trekRange: string | null; hostVersion: string } -/** What the range-bypass warning needs to say, and what to do if the admin accepts (null = a notice). */ -export interface RangeWarning extends TrekRangeBypass { name: string; onConfirm: (() => void) | null } +/** + * The server's "this only went through because the version check was set aside" marker: + * by an admin for this one plugin (`acceptedByAdmin`), or for every plugin through + * TREK_PLUGINS_IGNORE_TREK_RANGE. + */ +export interface TrekRangeBypass { trekRange: string | null; hostVersion: string; acceptedByAdmin?: boolean } +/** + * What the range-bypass warning needs to say, and what to do if the admin accepts (null = a + * notice). `action` names what the confirm button does: install (the default) or activate. + */ +export interface RangeWarning extends TrekRangeBypass { + name: string + onConfirm: (() => void) | null + action?: 'install' | 'activate' +} /** * The chip a row carries while it runs outside its declared range on the operator's @@ -16,29 +27,42 @@ export interface RangeWarning extends TrekRangeBypass { name: string; onConfirm: */ export function bypassChip(b: TrekRangeBypass | null | undefined, t: T) { if (!b) return null + const label = b.acceptedByAdmin + ? b.trekRange + ? t('admin.plugins.dep.trekAccepted', { range: b.trekRange, host: b.hostVersion }) + : t('admin.plugins.dep.trekAcceptedUnknown') + : b.trekRange + ? t('admin.plugins.dep.trekBypassed', { range: b.trekRange, host: b.hostVersion }) + : t('admin.plugins.dep.trekBypassedUnknown') return { icon: AlertTriangle, - label: b.trekRange - ? t('admin.plugins.dep.trekBypassed', { range: b.trekRange, host: b.hostVersion }) - : t('admin.plugins.dep.trekBypassedUnknown'), + label, blocked: false, warn: true, } } /** - * The Install button for a registry entry the server flagged incompatible, once the - * operator set TREK_PLUGINS_IGNORE_TREK_RANGE: the server will take the newest version - * regardless, so the button says exactly that — and carries what the warning has to say - * first. `title` is the shell's existing "why" line (needs TREK X — this server runs Y). + * The Install button for a registry entry the server flagged incompatible: the server will + * take the newest version regardless, so the button says exactly that — and carries what + * the warning has to say first. `byAdmin` is the admin's own "install anyway" for this one + * plugin; without it the operator set TREK_PLUGINS_IGNORE_TREK_RANGE for every plugin. + * `title` is the shell's existing "why" line (needs TREK X — this server runs Y). */ -export function bypassOffer(item: { name: string; trek?: string | null; hostVersion?: string }, t: T, title: string) { - return { - blocked: false, - label: t('admin.plugins.installAnyway'), - title, - warn: { name: item.name, trekRange: item.trek ?? null, hostVersion: item.hostVersion ?? '?', onConfirm: null } as RangeWarning, +export function bypassOffer( + item: { name: string; trek?: string | null; hostVersion?: string }, + t: T, + title: string, + byAdmin = false +) { + const warn: RangeWarning = { + name: item.name, + trekRange: item.trek ?? null, + hostVersion: item.hostVersion ?? '?', + acceptedByAdmin: byAdmin, + onConfirm: null, } + return { blocked: false, label: t('admin.plugins.install'), title, warn } } /** @@ -48,14 +72,29 @@ export function bypassOffer(item: { name: string; trek?: string | null; hostVers * whole point: the admin is accepting that the author never vouched for this TREK and * that, in rare cases, that can cost them data. */ -export function rangeWarningCopy(w: RangeWarning, t: T): { title: string; body: string; confirm: boolean } { +export function rangeWarningCopy( + w: RangeWarning, + t: T +): { title: string; body: string; confirm: boolean; confirmLabel: string } { const confirm = w.onConfirm !== null + const vars = { name: w.name, range: w.trekRange, host: w.hostVersion } + const body = w.acceptedByAdmin + ? w.trekRange + ? t('admin.plugins.rangeBypass.bodyAdmin', vars) + : t('admin.plugins.rangeBypass.bodyAdminUnknown', vars) + : w.trekRange + ? t('admin.plugins.rangeBypass.body', vars) + : t('admin.plugins.rangeBypass.bodyUnknown', vars) + const title = w.acceptedByAdmin + ? t('admin.plugins.rangeBypass.titleAdmin') + : confirm + ? t('admin.plugins.rangeBypass.title') + : t('admin.plugins.rangeBypass.noticeTitle') return { confirm, - title: confirm ? t('admin.plugins.rangeBypass.title') : t('admin.plugins.rangeBypass.noticeTitle'), - body: w.trekRange - ? t('admin.plugins.rangeBypass.body', { name: w.name, range: w.trekRange, host: w.hostVersion }) - : t('admin.plugins.rangeBypass.bodyUnknown', { name: w.name, host: w.hostVersion }), + title, + body, + confirmLabel: w.action === 'activate' ? t('admin.plugins.activateAnyway') : t('admin.plugins.installAnyway'), } } diff --git a/client/src/mobile/screens/admin/MAdminPluginsPanel.tsx b/client/src/mobile/screens/admin/MAdminPluginsPanel.tsx index d49dec817..b34e534dc 100644 --- a/client/src/mobile/screens/admin/MAdminPluginsPanel.tsx +++ b/client/src/mobile/screens/admin/MAdminPluginsPanel.tsx @@ -294,7 +294,7 @@ export default function MAdminPluginsPanel() { {bypass.copy && ( + confirmLabel={bypass.copy.confirmLabel} cancelLabel={bypass.copy.confirm ? t('common.cancel') : t('common.ok')} danger /> )} {/* Row ⋯ action sheet */} diff --git a/client/tests/unit/mobile/admin/MAdminPluginsPanel.test.tsx b/client/tests/unit/mobile/admin/MAdminPluginsPanel.test.tsx index 4d2e4b651..dfd56d45d 100644 --- a/client/tests/unit/mobile/admin/MAdminPluginsPanel.test.tsx +++ b/client/tests/unit/mobile/admin/MAdminPluginsPanel.test.tsx @@ -359,12 +359,22 @@ describe('MAdminPluginsPanel — Discover', () => { await waitFor(() => expect(toastMessages()).toContain('Installed')); }); - it('FE-MOB-PLUGP-018: an entry no published version fits gets a dead, explained button', async () => { + it('FE-MOB-PLUGP-018: an entry no published version fits installs only after the admin accepts the risk', async () => { + let body: unknown = null; await openDiscover([registryEntry({ trek: '>=4.0.0', hostVersion: '3.3.0', compatible: false, latestCompatible: null })]); + server.use(http.post('*/api/admin/plugins/install', async ({ request }) => { + body = await request.json(); + return HttpResponse.json({ id: 'trek-gotify', version: '2.0.0', trekRangeBypassed: null }); + })); - const btn = await screen.findByRole('button', { name: 'Incompatible' }); - expect(btn).toBeDisabled(); + const btn = await screen.findByRole('button', { name: /^Install$/ }); expect(btn).toHaveAttribute('title', 'Needs TREK >=4.0.0 — this server runs 3.3.0'); + fireEvent.click(btn); + + const sheet = await screen.findByRole('dialog', { name: 'Untested on this TREK' }); + expect(body).toBeNull(); + fireEvent.click(within(sheet).getByRole('button', { name: 'Install anyway' })); + await waitFor(() => expect(body).toEqual({ id: 'trek-gotify', acceptTrekRange: true })); }); it('FE-MOB-PLUGP-019: when only an older release fits, that version is offered and installed', async () => { @@ -1806,7 +1816,7 @@ describe('MAdminPluginsPanel — TREK-range bypass (TREK_PLUGINS_IGNORE_TREK_RAN expect(await screen.findByText('Version checks off')).toBeInTheDocument(); }); - it('FE-MOB-PLUGP-BYPASS-002: offers "Install anyway" and asks before installing', async () => { + it('FE-MOB-PLUGP-BYPASS-002: offers Install and asks before installing', async () => { let body: unknown = null; mockPanel([], [incompatibleEntry()], { ignoreTrekRange: true }); server.use(http.post('*/api/admin/plugins/install', async ({ request }) => { @@ -1816,7 +1826,7 @@ describe('MAdminPluginsPanel — TREK-range bypass (TREK_PLUGINS_IGNORE_TREK_RAN render(); fireEvent.click(await screen.findByRole('tab', { name: /Discover/ })); - const btn = await screen.findByRole('button', { name: 'Install anyway' }); + const btn = await screen.findByRole('button', { name: /^Install$/ }); expect(btn).toBeEnabled(); fireEvent.click(btn); @@ -1834,7 +1844,7 @@ describe('MAdminPluginsPanel — TREK-range bypass (TREK_PLUGINS_IGNORE_TREK_RAN server.use(http.post('*/api/admin/plugins/install', () => { posted = true; return HttpResponse.json({ id: 'trek-gotify', version: '2.0.0', trekRangeBypassed: null }); })); render(); fireEvent.click(await screen.findByRole('tab', { name: /Discover/ })); - fireEvent.click(await screen.findByRole('button', { name: 'Install anyway' })); + fireEvent.click(await screen.findByRole('button', { name: /^Install$/ })); const sheet = await screen.findByRole('dialog', { name: 'Outside its supported TREK versions' }); fireEvent.click(within(sheet).getByRole('button', { name: 'Cancel' })); diff --git a/server/scripts/size-baseline.json b/server/scripts/size-baseline.json index d2c045003..934b2cc4a 100644 --- a/server/scripts/size-baseline.json +++ b/server/scripts/size-baseline.json @@ -13,7 +13,7 @@ "src/nest/doc-sync/providers/synology-drive.client.ts": 1160, "src/nest/journey/journey-domain.service.ts": 1659, "src/nest/places/places.service.ts": 1592, - "src/nest/plugins/plugin-runtime.service.ts": 1463, + "src/nest/plugins/plugin-runtime.service.ts": 1460, "src/nest/plugins/runtime/plugin-sdk.ts": 1093, "src/nest/reservations/reservations.service.ts": 1237, "src/nest/trips/trips.service.ts": 1006, diff --git a/server/src/db/entities/Plugins.entity.ts b/server/src/db/entities/Plugins.entity.ts index 53d060d15..42f4c67f0 100644 --- a/server/src/db/entities/Plugins.entity.ts +++ b/server/src/db/entities/Plugins.entity.ts @@ -35,6 +35,8 @@ export class Plugins { update_block_version?: string | null; trek_range?: string | null; update_hold: number & Opt = 0; + trek_range_accepted_version?: string | null; + trek_range_accepted_host?: string | null; } export const PluginsSchema = defineEntity({ @@ -72,5 +74,7 @@ export const PluginsSchema = defineEntity({ update_block_version: p.text().nullable(), trek_range: p.text().nullable(), update_hold: p.integer().default(0), + trek_range_accepted_version: p.text().nullable(), + trek_range_accepted_host: p.text().nullable(), }, }); diff --git a/server/src/db/kysely/Plugins.table.ts b/server/src/db/kysely/Plugins.table.ts index ea015f0b4..e7dffcf4a 100644 --- a/server/src/db/kysely/Plugins.table.ts +++ b/server/src/db/kysely/Plugins.table.ts @@ -34,4 +34,6 @@ export interface PluginsTable { update_block_version: InsertOptional; trek_range: InsertOptional; update_hold: InsertOptional; + trek_range_accepted_version: InsertOptional; + trek_range_accepted_host: InsertOptional; } diff --git a/server/src/db/migrations/Migration20200101046310_an_admin_can_run_a_plugin_outside_its_trek_range.ts b/server/src/db/migrations/Migration20200101046310_an_admin_can_run_a_plugin_outside_its_trek_range.ts new file mode 100644 index 000000000..a9feb9ff9 --- /dev/null +++ b/server/src/db/migrations/Migration20200101046310_an_admin_can_run_a_plugin_outside_its_trek_range.ts @@ -0,0 +1,18 @@ +import { Migration } from '@mikro-orm/migrations'; + +/** + * An admin can run one plugin outside the TREK versions its author declared, + * instead of switching the check off for every plugin through + * TREK_PLUGINS_IGNORE_TREK_RANGE. The consent belongs to what the admin was + * shown: `trek_range_accepted_version` is the plugin version they accepted and + * `trek_range_accepted_host` the TREK version it ran on then. An update of the + * plugin or a new TREK major asks again. + */ +export class Migration20200101046310_an_admin_can_run_a_plugin_outside_its_trek_range extends Migration { + override name = 'Migration20200101046310_an_admin_can_run_a_plugin_outside_its_trek_range'; + + override up(): void { + this.addSql('ALTER TABLE plugins ADD COLUMN trek_range_accepted_version TEXT'); + this.addSql('ALTER TABLE plugins ADD COLUMN trek_range_accepted_host TEXT'); + } +} diff --git a/server/src/db/repositories/Plugins.repository.ts b/server/src/db/repositories/Plugins.repository.ts index bc7aa4caa..093c692eb 100644 --- a/server/src/db/repositories/Plugins.repository.ts +++ b/server/src/db/repositories/Plugins.repository.ts @@ -26,13 +26,16 @@ export interface PluginIdPermissionRow { permissions: string | null; } -/** PR17 — `SELECT permissions, granted_permissions, dependencies, trek_range, api_version FROM plugins WHERE id = ?` (`assertActivatable`'s one-row activation gate). */ +/** PR17 — the one-row activation gate `assertActivatable` reads: grants, dependencies, the TREK range and an admin's consent to run outside it. */ export interface PluginActivationGateRow { permissions: string; granted_permissions: string; dependencies: string | null; trek_range: string | null; api_version: number | null; + version: string | null; + trek_range_accepted_version: string | null; + trek_range_accepted_host: string | null; } /** PR18 — `SELECT permissions, config FROM plugins WHERE id = ?` (`spawnActivated`). */ @@ -89,6 +92,8 @@ export interface PluginAdminRow { dependencies: string | null; operator_egress: number; trek_range: string | null; + trek_range_accepted_version: string | null; + trek_range_accepted_host: string | null; author_pubkey: string | null; update_block_code: string | null; update_block_detail: string | null; @@ -144,7 +149,18 @@ export class PluginsRepository extends TrekRepository { async findActivationGate(id: string): Promise { const row = await this.findOne( { id }, - { fields: ['permissions', 'granted_permissions', 'dependencies', 'trek_range', 'api_version'] }, + { + fields: [ + 'permissions', + 'granted_permissions', + 'dependencies', + 'trek_range', + 'api_version', + 'version', + 'trek_range_accepted_version', + 'trek_range_accepted_host', + ], + }, ); return row ? { @@ -153,6 +169,9 @@ export class PluginsRepository extends TrekRepository { dependencies: row.dependencies ?? null, trek_range: row.trek_range ?? null, api_version: row.api_version ?? null, + version: row.version ?? null, + trek_range_accepted_version: row.trek_range_accepted_version ?? null, + trek_range_accepted_host: row.trek_range_accepted_host ?? null, } : null; } @@ -370,6 +389,8 @@ export class PluginsRepository extends TrekRepository { 'dependencies', 'operator_egress', 'trek_range', + 'trek_range_accepted_version', + 'trek_range_accepted_host', 'author_pubkey', 'update_block_code', 'update_block_detail', @@ -396,6 +417,8 @@ export class PluginsRepository extends TrekRepository { dependencies: r.dependencies ?? null, operator_egress: r.operator_egress, trek_range: r.trek_range ?? null, + trek_range_accepted_version: r.trek_range_accepted_version ?? null, + trek_range_accepted_host: r.trek_range_accepted_host ?? null, author_pubkey: r.author_pubkey ?? null, update_block_code: r.update_block_code ?? null, update_block_detail: r.update_block_detail ?? null, @@ -545,6 +568,11 @@ export class PluginsRepository extends TrekRepository { await this.nativeUpdate({ id }, { update_hold: hold ? 1 : 0 }); } + /** An admin accepted running this plugin version outside its TREK range, on this TREK. */ + async acceptTrekRange(id: string, version: string, host: string): Promise { + await this.nativeUpdate({ id }, { trek_range_accepted_version: version, trek_range_accepted_host: host }); + } + /** * RG8 (`commitUpload`, sideload step 2) — `` UPDATE plugins SET source_repo = ?, * source_commit = NULL, sha256 = NULL, reviewed_at = NULL, author_pubkey = NULL, diff --git a/server/src/nest/plugins/install/host-compat.ts b/server/src/nest/plugins/install/host-compat.ts index ec9b8e281..9ecd59522 100644 --- a/server/src/nest/plugins/install/host-compat.ts +++ b/server/src/nest/plugins/install/host-compat.ts @@ -126,18 +126,98 @@ export interface TrekRangeBypass { /** The plugin's declared range, or null when it declared none at all. */ trekRange: string | null; hostVersion: string; + /** + * True when an admin accepted this one plugin outside its range, false when + * TREK_PLUGINS_IGNORE_TREK_RANGE lets every plugin through. + */ + acceptedByAdmin: boolean; +} + +/** An admin's consent to run one plugin outside its range, as the plugins row keeps it. */ +interface TrekRangeAcceptance { + /** The plugin version that was installed when the admin accepted. */ + acceptedVersion: string | null; + /** The TREK version it ran on then. */ + acceptedHost: string | null; } /** - * Non-null exactly when `range` would have been refused and only the bypass lets it - * through: the marker that makes an install/activate response say "this worked, but - * only because you told TREK to ignore the version check". Null for a normal install - * (the range fits) and null when the bypass is off (the gate refuses instead). + * Whether an admin's consent still covers the plugin as it is installed now: the + * same plugin version, on the same TREK major. An update of the plugin, or a TREK + * major past the one the admin saw it run on, asks again. */ -export function bypassedRange(range: string | null | undefined): TrekRangeBypass | null { +export function acceptanceHolds( + pluginVersion: string | null | undefined, + a: TrekRangeAcceptance | null | undefined, +): boolean { + if (!a?.acceptedVersion || !a.acceptedHost || !pluginVersion) return false; + if (a.acceptedVersion !== pluginVersion) return false; + const accepted = semver.coerce(a.acceptedHost); + const host = normalizedHost(); + if (!accepted || host === null) return false; + return semver.major(accepted) === semver.major(host); +} + +/** + * Non-null exactly when `range` would have been refused and only a bypass lets it + * through: the marker that makes an install/activate response say "this worked, but + * only because you told TREK to run it anyway". `accepted` is this one plugin's + * consent from the admin; the environment switch covers every plugin. Null for a + * normal install (the range fits) and null when neither applies (the gate refuses). + */ +export function bypassedRange(range: string | null | undefined, accepted = false): TrekRangeBypass | null { if (hostSatisfies(range)) return null; - if (!trekRangeBypassed()) return null; - return { trekRange: range ?? null, hostVersion: hostVersion() }; + const everyPlugin = trekRangeBypassed(); + if (!everyPlugin && !accepted) return null; + return { trekRange: range ?? null, hostVersion: hostVersion(), acceptedByAdmin: !everyPlugin }; +} + +/** What the activation gate reads off a plugin's row to decide whether it may run on this TREK. */ +interface TrekRangeRow { + trek_range: string | null; + version: string | null; + trek_range_accepted_version: string | null; + trek_range_accepted_host: string | null; +} + +/** Why the activation gate refuses a plugin on this TREK, in the shape its error carries. */ +interface TrekRangeRefusal { + message: string; + code: 'TREK_VERSION_UNKNOWN' | 'TREK_VERSION_INCOMPATIBLE'; + detail: { trekRange: string | null; hostVersion: string }; +} + +/** + * The TREK-version half of the activation gate: null when the plugin may run, else why + * not. An admin's consent (`acceptNow`, or one kept on the row for this version and TREK + * major) or TREK_PLUGINS_IGNORE_TREK_RANGE lets an outgrown plugin through, and every + * start of one says so in the log; the plugin list carries the same marker for the UI. + */ +export function trekRangeRefusal(id: string, row: TrekRangeRow, acceptNow = false): TrekRangeRefusal | null { + const accepted = + acceptNow || + acceptanceHolds(row.version, { + acceptedVersion: row.trek_range_accepted_version, + acceptedHost: row.trek_range_accepted_host, + }); + const bypass = bypassedRange(row.trek_range, accepted); + if (bypass) { + warnRangeBypass(id, bypass); + return null; + } + if (!row.trek_range) { + return { + message: `plugin ${id} does not declare which TREK versions it supports`, + code: 'TREK_VERSION_UNKNOWN', + detail: { trekRange: null, hostVersion: hostVersion() }, + }; + } + if (hostSatisfies(row.trek_range)) return null; + return { + message: `plugin ${id} requires TREK ${row.trek_range} — this is TREK ${hostVersion()}`, + code: 'TREK_VERSION_INCOMPATIBLE', + detail: { trekRange: row.trek_range, hostVersion: hostVersion() }, + }; } /** @@ -147,7 +227,8 @@ export function bypassedRange(range: string | null | undefined): TrekRangeBypass */ export function warnRangeBypass(id: string, b: TrekRangeBypass): void { const declared = b.trekRange ? `declares TREK ${b.trekRange}` : 'declares no TREK version range'; + const because = b.acceptedByAdmin ? 'an admin chose to run it anyway' : 'TREK_PLUGINS_IGNORE_TREK_RANGE is set'; console.warn( - `[plugins] ${id} ${declared} — this is TREK ${b.hostVersion}; continuing because TREK_PLUGINS_IGNORE_TREK_RANGE is set. The author has not verified it on this version: it may not work, and it could corrupt TREK data.`, + `[plugins] ${id} ${declared} — this is TREK ${b.hostVersion}; continuing because ${because}. The author has not verified it on this version: it may not work, and it could corrupt TREK data.`, ); } diff --git a/server/src/nest/plugins/plugin-runtime.service.ts b/server/src/nest/plugins/plugin-runtime.service.ts index 8e5567fdf..e18bec42a 100644 --- a/server/src/nest/plugins/plugin-runtime.service.ts +++ b/server/src/nest/plugins/plugin-runtime.service.ts @@ -62,7 +62,7 @@ import { scanForNativeBinaries } from './install/native-scan'; import { devLinkEnabled, DEV_LINK_SOURCE } from './dev-link'; import { pluginCodeDir, pluginDataDir } from './paths'; import { assertHostCompatible, PluginRegistryService, RegistryError } from './registry/registry.service'; -import { hostSatisfies, hostVersion, bypassedRange, warnRangeBypass } from './install/host-compat'; +import { hostSatisfies, hostVersion, trekRangeRefusal } from './install/host-compat'; import type { TrekRangeBypass } from './install/host-compat'; import { keyFingerprint } from './signature-status'; import { AuditService } from '../audit/audit.service'; @@ -639,8 +639,10 @@ export class PluginRuntimeService implements OnApplicationBootstrap, OnModuleDes * freshly-installed plugin (no prior grant) is itself the consent for its * declared set; widening an already-consented set requires `consentWiden` (the * update consent dialog), and otherwise throws PluginConsentRequired. + * `acceptTrekRange` is the admin's "run it anyway" outside the TREK range the author + * declared: for this plugin only, never a dependency, kept on its row for this version. */ - async activate(id: string, consentWiden = false): Promise { + async activate(id: string, consentWiden = false, acceptTrekRange = false): Promise { const installed = await this.installedDepRows(); // Deps-first order over the installed graph (throws DependencyCycleError on a // cycle). Missing deps aren't in `installed` so they don't appear here — the @@ -652,6 +654,7 @@ export class PluginRuntimeService implements OnApplicationBootstrap, OnModuleDes // blocked dependency never leaves the chain half-activated. Only the target // may consent-widen; dependencies are auto-enabled at their existing grant. const toCheck = rootInstalled ? order : [id]; + if (acceptTrekRange) await this.rememberRangeAcceptance(id); for (const nodeId of toCheck) await this.assertActivatable(nodeId, installed, nodeId === id ? consentWiden : false); // Enable dependencies first (skip ones already enabled), then the target. @@ -661,6 +664,16 @@ export class PluginRuntimeService implements OnApplicationBootstrap, OnModuleDes } } + /** + * Keeps the admin's "run it anyway" for the installed version, when it is in fact outside its range. + * @txStandalone the admin's answer to a dialog; it stands whether or not the plugin then starts. + */ + private async rememberRangeAcceptance(id: string): Promise { + const row = await this.plugins.findActivationGate(id); + if (!row?.version || hostSatisfies(row.trek_range)) return; + await this.plugins.acceptTrekRange(id, row.version, hostVersion()); + } + /** All plugin rows projected to what the dependency helpers reason over. */ private async installedDepRows(): Promise> { const rows: PluginDepRow[] = await this.plugins.listDepRows(); @@ -682,24 +695,8 @@ export class PluginRuntimeService implements OnApplicationBootstrap, OnModuleDes // it would not make the plugin start. It is also the gate that catches the case // install can't — TREK was upgraded PAST the plugin's declared upper bound, so code // that was legitimately installed no longer supports the host it's sitting on. - const bypass = bypassedRange(row.trek_range); - if (bypass) { - // TREK_PLUGINS_IGNORE_TREK_RANGE: the operator chose to run it anyway. Say so in the - // log every time it starts — the plugin list carries the same marker for the UI. - warnRangeBypass(id, bypass); - } else if (!row.trek_range) { - throw new PluginDependencyError( - `plugin ${id} does not declare which TREK versions it supports`, - 'TREK_VERSION_UNKNOWN', - { trekRange: null, hostVersion: hostVersion() }, - ); - } else if (!hostSatisfies(row.trek_range)) { - throw new PluginDependencyError( - `plugin ${id} requires TREK ${row.trek_range} — this is TREK ${hostVersion()}`, - 'TREK_VERSION_INCOMPATIBLE', - { trekRange: row.trek_range, hostVersion: hostVersion() }, - ); - } + const refusal = trekRangeRefusal(id, row); + if (refusal) throw new PluginDependencyError(refusal.message, refusal.code, refusal.detail); // Same reasoning as the TREK-version gate above: a plugin whose manifest apiVersion // outpaces this TREK's plugin-API surface can never run correctly, so it must be // refused before any consent dialog is offered. diff --git a/server/src/nest/plugins/plugins.controller.ts b/server/src/nest/plugins/plugins.controller.ts index 7f22c8342..5516d23b9 100644 --- a/server/src/nest/plugins/plugins.controller.ts +++ b/server/src/nest/plugins/plugins.controller.ts @@ -113,8 +113,15 @@ export class PluginsController { // compatible version and reporting addons the admin still has to enable. // Dependency resolution pins versions internally but never DELIBERATELY, so // only the plain path below recomputes the update hold. - if (body.withDependencies) return await this.registry.installWithDependencies(body.id, body.constraint); - const res = await this.registry.install(body.id, { version: body.version, constraint: body.constraint }); + const acceptTrekRange = body.acceptTrekRange === true; + if (body.withDependencies) { + return await this.registry.installWithDependencies(body.id, body.constraint, acceptTrekRange); + } + const res = await this.registry.install(body.id, { + version: body.version, + constraint: body.constraint, + acceptTrekRange, + }); await this.registry.recomputeUpdateHold(res.id, res.version, !!body.version); return res; } catch (e) { @@ -272,7 +279,7 @@ export class PluginsController { async activate(@Param('id') id: string, @Body() body: PluginActivateDto) { if (!pluginsEnabled()) throw new HttpException({ error: 'Plugins are disabled by server configuration' }, 503); try { - await this.runtime.activate(id, !!body?.consent); + await this.runtime.activate(id, !!body?.consent, body?.acceptTrekRange === true); } catch (e) { // Re-enabling a plugin whose update widened its permissions must NOT grant // them silently — surface a distinct code so the UI opens the consent dialog. diff --git a/server/src/nest/plugins/plugins.service.ts b/server/src/nest/plugins/plugins.service.ts index b469ba4fc..bf4e5d313 100644 --- a/server/src/nest/plugins/plugins.service.ts +++ b/server/src/nest/plugins/plugins.service.ts @@ -25,7 +25,7 @@ import { import { devLinkEnabled } from './dev-link'; import { readAudit } from './host/plugin-audit'; import { pluginBudgetUsage } from './host/plugin-host-state'; -import { bypassedRange, hostSatisfies, hostVersion, trekRangeBypassed } from './install/host-compat'; +import { acceptanceHolds, bypassedRange, hostSatisfies, hostVersion, trekRangeBypassed } from './install/host-compat'; import type { TrekRangeBypass } from './install/host-compat'; import type { PluginDependency } from './install/manifest'; import { pluginsEnabled } from './kill-switch'; @@ -71,6 +71,8 @@ interface PluginRawRow { capabilities: string; dependencies: string | null; trek_range: string | null; + trek_range_accepted_version: string | null; + trek_range_accepted_host: string | null; author_pubkey: string | null; update_block_code: string | null; update_block_detail: string | null; @@ -113,9 +115,10 @@ export interface PluginListItem { /** The running TREK, so the UI can say "needs X, you have Y" without doing semver. */ hostVersion: string; /** - * Non-null when the plugin is outside its declared range (or declared none) and only - * TREK_PLUGINS_IGNORE_TREK_RANGE lets it activate. The card shows it as a warning - * rather than a blocker: the admin chose this, but must keep seeing what they chose. + * Non-null when the plugin is outside its declared range (or declared none) and only an + * admin's "run it anyway" for this plugin, or TREK_PLUGINS_IGNORE_TREK_RANGE, lets it + * activate. The card shows it as a warning rather than a blocker: the admin chose this, + * but must keep seeing what they chose. */ trekRangeBypassed: TrekRangeBypass | null; /** The concrete blockers, so the UI can render chips + the resolve dialog. */ @@ -200,7 +203,13 @@ export class PluginsService { const state = resolveDependencyState(deps, installed); // Mirrors the order of assertActivatable's gate, so the card explains the same // blocker the activate call would hit rather than a second, lesser one. - const trekBypass = bypassedRange(r.trek_range); + const trekBypass = bypassedRange( + r.trek_range, + acceptanceHolds(r.version, { + acceptedVersion: r.trek_range_accepted_version, + acceptedHost: r.trek_range_accepted_host, + }), + ); const dependencyStatus: PluginDependencyStatus = !hostSatisfies(r.trek_range) && !trekBypass ? 'hostIncompatible' @@ -213,6 +222,8 @@ export class PluginsService { dependencies: _raw, operator_egress: _oe, trek_range, + trek_range_accepted_version: _acceptedVersion, + trek_range_accepted_host: _acceptedHost, author_pubkey, update_block_code, update_block_detail, diff --git a/server/src/nest/plugins/registry/registry.service.ts b/server/src/nest/plugins/registry/registry.service.ts index 021322e1c..7959baf68 100644 --- a/server/src/nest/plugins/registry/registry.service.ts +++ b/server/src/nest/plugins/registry/registry.service.ts @@ -392,11 +392,11 @@ export class PluginRegistryService { * version (`minTrekVersion`/`maxTrekVersion`). Throws if the plugin isn't in the * registry or nothing qualifies. Backs "download the latest compatible version". */ - async resolveVersion(id: string, constraint?: string): Promise { + async resolveVersion(id: string, constraint?: string, acceptTrekRange = false): Promise { const reg = await this.fetchRegistry(); const entry = reg.plugins.find((p) => p.id === id); if (!entry) throw new RegistryError(`plugin ${id} not in registry`); - const v = this.latestCompatible(entry, constraint); + const v = this.latestCompatible(entry, constraint) ?? (acceptTrekRange ? this.newestOf(entry, constraint) : null); if (!v) { throw new RegistryError( constraint @@ -427,6 +427,15 @@ export class PluginRegistryService { return [...candidates].sort((a, b) => semver.rcompare(a.version, b.version))[0]; } + /** + * The newest version that satisfies `constraint`, whatever TREK it declares: what an + * admin's "install anyway" takes when no version fits this TREK. A version that does + * fit is always preferred over this (see {@link selectVersion}). + */ + private newestOf(entry: RegistryEntry, constraint?: string): RegistryVersion | null { + return this.latestCompatible(entry, constraint, () => true); + } + /** * Pick the version `install()` will fetch, and refuse an incompatible one — on EVERY * path, which is the whole point. An explicit `version` and the bare "install latest" @@ -434,11 +443,15 @@ export class PluginRegistryService { * through resolveVersion), so the admin UI's own Install button — which sends neither — * happily installed a plugin that declared it doesn't support this TREK. */ - private selectVersion(entry: RegistryEntry, opts?: { version?: string; constraint?: string }): RegistryVersion { + private selectVersion( + entry: RegistryEntry, + opts?: { version?: string; constraint?: string; acceptTrekRange?: boolean }, + ): RegistryVersion { + const accept = opts?.acceptTrekRange === true; if (opts?.version) { const ver = entry.versions.find((v) => v.version === opts.version); if (!ver) throw new RegistryError(`version ${opts.version} not found for ${entry.id}`); - if (!installable(ver, normalizedHost())) { + if (!accept && !installable(ver, normalizedHost())) { throw new RegistryError( `${entry.id} ${ver.version} requires TREK ${trekRequirement(ver)} — this is TREK ${hostVersion()}`, 'TREK_VERSION_INCOMPATIBLE', @@ -446,7 +459,8 @@ export class PluginRegistryService { } return ver; } - const ver = this.latestCompatible(entry, opts?.constraint); + const ver = + this.latestCompatible(entry, opts?.constraint) ?? (accept ? this.newestOf(entry, opts?.constraint) : null); if (ver) return ver; // Nothing compatible. Say WHY against the newest version the admin was actually // shown, rather than a bare "not found" — the fix is a TREK upgrade, not a retry. @@ -476,7 +490,7 @@ export class PluginRegistryService { */ async install( id: string, - opts?: { version?: string; constraint?: string; retrustKey?: string }, + opts?: { version?: string; constraint?: string; retrustKey?: string; acceptTrekRange?: boolean }, ): Promise<{ id: string; version: string; trekRangeBypassed: TrekRangeBypass | null }> { const reg = await this.fetchRegistry(); const entry = reg.plugins.find((p) => p.id === id); @@ -518,7 +532,7 @@ export class PluginRegistryService { // published entries usually carry a lower bound and no upper one at all, so a // plugin that declares "<4.0.0" passes the pre-download filter on TREK 4 and is // caught only here. - const trekRangeBypassed = assertHostCompatible(manifest.trekRange, id); + const trekRangeBypassed = assertHostCompatible(manifest.trekRange, id, opts?.acceptTrekRange === true); if (scanForNativeBinaries(pluginRoot).length) throw new RegistryError('artifact contains native binaries'); // 6. atomic move into place @@ -543,6 +557,9 @@ export class PluginRegistryService { // The plugin is now on new code that passed every check — whatever refusal was // recorded before no longer describes reality. await clearUpdateBlock(this.plugins, id); + // The admin's "install anyway" for this plugin, kept for the version that landed + // so activating it later does not ask a second time. + if (trekRangeBypassed?.acceptedByAdmin) await this.plugins.acceptTrekRange(id, ver.version, hostVersion()); }); return { id, version: ver.version, trekRangeBypassed }; } finally { @@ -560,6 +577,7 @@ export class PluginRegistryService { async installWithDependencies( id: string, constraint?: string, + acceptTrekRange = false, ): Promise<{ installed: string[]; requiredAddons: string[]; trekRangeBypassed: TrekRangeBypass | null }> { let trekRangeBypassed: TrekRangeBypass | null = null; const installedNow = new Set(await this.plugins.listAllIds()); @@ -570,12 +588,14 @@ export class PluginRegistryService { const visit = async (pid: string, range: string | undefined, stack: string[]): Promise => { if (done.has(pid)) return; if (stack.includes(pid)) throw new RegistryError(`plugin dependency cycle: ${[...stack, pid].join(' -> ')}`); - const ver = await this.resolveVersion(pid, range); // throws if not in registry / unsatisfiable + // The admin's "install anyway" covers the plugin they asked for, never a dependency. + const acceptHere = acceptTrekRange && pid === id; + const ver = await this.resolveVersion(pid, range, acceptHere); // throws if not in registry / unsatisfiable if (!installedNow.has(pid)) { // Install the version we just RESOLVED. Passing the range back (or nothing, for // the root) made install() re-pick on its own and land on entry.versions[0] — // so the compatible version resolveVersion had chosen was computed and discarded. - const res = await this.install(pid, { version: ver.version }); + const res = await this.install(pid, { version: ver.version, acceptTrekRange: acceptHere }); // The admin asked for the root; that is the bypass they are warned about (a // dependency's own bypass still lands in the log). if (pid === id) trekRangeBypassed = res.trekRangeBypassed; @@ -817,13 +837,14 @@ function installable(v: RegistryVersion, host: string | null): boolean { * every install front door (registry, sideload, dev-link) so they all fail the same way, * with a code the admin UI can act on rather than prose it would have to string-match. * - * Under TREK_PLUGINS_IGNORE_TREK_RANGE the refusal becomes a log warning, and the - * returned marker is what the front door hands back so the admin is told too. Null - * means the range fits and nothing was bypassed. + * Under TREK_PLUGINS_IGNORE_TREK_RANGE, or when the admin `accepted` this one plugin + * outside its range, the refusal becomes a log warning, and the returned marker is what + * the front door hands back so the admin is told too. Null means the range fits and + * nothing was bypassed. */ -export function assertHostCompatible(range: string | null, id: string): TrekRangeBypass | null { +export function assertHostCompatible(range: string | null, id: string, accepted = false): TrekRangeBypass | null { if (hostSatisfies(range)) return null; - const bypass = bypassedRange(range); + const bypass = bypassedRange(range, accepted); if (bypass) { warnRangeBypass(id, bypass); return bypass; diff --git a/server/tests/integration/plugins/dev-link.test.ts b/server/tests/integration/plugins/dev-link.test.ts index 9ca15905d..e32a0c7c6 100644 --- a/server/tests/integration/plugins/dev-link.test.ts +++ b/server/tests/integration/plugins/dev-link.test.ts @@ -117,11 +117,11 @@ describe('PluginRuntimeService dev-link', () => { try { await expect(runtime.link(writeSource('oldplug-bypass', { trek: '>=2.0.0 <3.0.0' }))).resolves.toMatchObject({ id: 'oldplug-bypass', - trekRangeBypassed: { trekRange: '>=2.0.0 <3.0.0', hostVersion: '3.3.0' }, + trekRangeBypassed: { trekRange: '>=2.0.0 <3.0.0', hostVersion: '3.3.0', acceptedByAdmin: false }, }); await expect(runtime.link(writeSource('rangeless-bypass', { trek: '' }))).resolves.toMatchObject({ id: 'rangeless-bypass', - trekRangeBypassed: { trekRange: null, hostVersion: '3.3.0' }, + trekRangeBypassed: { trekRange: null, hostVersion: '3.3.0', acceptedByAdmin: false }, }); // A dir that fits is a plain link — no marker to alarm anyone with. await expect(runtime.link(writeSource('fits-bypass'))).resolves.toMatchObject({ trekRangeBypassed: null }); diff --git a/server/tests/integration/plugins/plugin-runtime.test.ts b/server/tests/integration/plugins/plugin-runtime.test.ts index 6bf444520..f5100ca8d 100644 --- a/server/tests/integration/plugins/plugin-runtime.test.ts +++ b/server/tests/integration/plugins/plugin-runtime.test.ts @@ -818,14 +818,27 @@ describe('PluginRuntimeService.retrust', () => { * host it declared it doesn't support — would otherwise be spawned on the next boot. */ describe('TREK host-version gating', () => { - const seedRanged = (id: string, trekRange: string | null, enabled = 0) => { + const seedRanged = ( + id: string, + trekRange: string | null, + enabled = 0, + accepted: { version?: string; acceptedVersion?: string; acceptedHost?: string } = {}, + ) => { fs.mkdirSync(path.join(codeRoot, id, 'server'), { recursive: true }); fs.writeFileSync(path.join(codeRoot, id, 'server', 'index.js'), 'module.exports = { async onLoad() {} };'); testDb .prepare( - "INSERT INTO plugins (id, status, enabled, permissions, granted_permissions, config, trek_range) VALUES (?, ?, ?, '[]', '[]', '{}', ?)", + "INSERT INTO plugins (id, status, enabled, permissions, granted_permissions, config, trek_range, version, trek_range_accepted_version, trek_range_accepted_host) VALUES (?, ?, ?, '[]', '[]', '{}', ?, ?, ?, ?)", ) - .run(id, enabled ? 'active' : 'inactive', enabled, trekRange); + .run( + id, + enabled ? 'active' : 'inactive', + enabled, + trekRange, + accepted.version ?? null, + accepted.acceptedVersion ?? null, + accepted.acceptedHost ?? null, + ); }; const cleanup = (...ids: string[]) => { for (const id of ids) testDb.prepare('DELETE FROM plugins WHERE id = ?').run(id); @@ -919,6 +932,43 @@ describe('TREK host-version gating', () => { }); }); + it("runs an outgrown plugin on the admin's say-so, and does not ask a second time", async () => { + process.env.APP_VERSION = '5.0.0'; + seedRanged('say-so', '>=3.2.0 <4.0.0', 0, { version: '1.0.3' }); + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const rt = await createPluginRuntime(dbConn); + await rt.activate('say-so', false, true); + expect(rt.isActive('say-so')).toBe(true); + expect(warn.mock.calls.some((c) => /say-so.*an admin chose to run it anyway/.test(String(c[0])))).toBe(true); + await rt.deactivate('say-so'); + + // The consent was kept with the row, so switching it on again needs no second one. + await rt.activate('say-so'); + expect(rt.isActive('say-so')).toBe(true); + await rt.deactivate('say-so'); + warn.mockRestore(); + cleanup('say-so'); + }); + + it("keeps the admin's consent for the version and TREK major it was given on only", async () => { + const given = { acceptedVersion: '1.0.3', acceptedHost: '5.0.0' }; + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + process.env.APP_VERSION = '5.0.1'; + seedRanged('kept', '>=3.2.0 <4.0.0', 0, { version: '1.0.3', ...given }); + seedRanged('updated', '>=3.2.0 <4.0.0', 0, { version: '1.1.0', ...given }); + const rt = await createPluginRuntime(dbConn); + await rt.activate('kept'); + expect(rt.isActive('kept')).toBe(true); + await rt.deactivate('kept'); + // An update of the plugin asks again. + await expect(rt.activate('updated')).rejects.toMatchObject({ code: 'TREK_VERSION_INCOMPATIBLE' }); + // So does a new TREK major. + process.env.APP_VERSION = '6.0.0'; + await expect(rt.activate('kept')).rejects.toMatchObject({ code: 'TREK_VERSION_INCOMPATIBLE' }); + warn.mockRestore(); + cleanup('kept', 'updated'); + }); + it('checks the version BEFORE permissions — an unrunnable plugin gets no consent dialog', async () => { // Consenting to permissions would not make it start, so offering the dialog is a lie. process.env.APP_VERSION = '4.0.0'; diff --git a/server/tests/integration/plugins/registry.test.ts b/server/tests/integration/plugins/registry.test.ts index 2dcebdb1f..5177a0aa1 100644 --- a/server/tests/integration/plugins/registry.test.ts +++ b/server/tests/integration/plugins/registry.test.ts @@ -1193,6 +1193,43 @@ describe('TREK-version gating on install', () => { expect(safeDownload).not.toHaveBeenCalled(); }); + it("an admin's install-anyway takes the newest version when none fits, and keeps that consent on the row", async () => { + process.env.APP_VERSION = '4.0.0'; // both versions declare <4.0.0 + stubMulti(); + stageMulti('2.1.0', 0); + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + await expect(svc.install('multi', { acceptTrekRange: true })).resolves.toEqual({ + id: 'multi', + version: '2.1.0', + trekRangeBypassed: { trekRange: '>=3.4.0 <4.0.0', hostVersion: '4.0.0', acceptedByAdmin: true }, + }); + expect(warn.mock.calls.some((c) => /multi.*an admin chose to run it anyway/.test(String(c[0])))).toBe(true); + warn.mockRestore(); + const row = await findRow(t, Plugins, { id: 'multi' }); + expect(row).toMatchObject({ trek_range_accepted_version: '2.1.0', trek_range_accepted_host: '4.0.0' }); + }); + + it("an admin's install-anyway still prefers a version that fits", async () => { + process.env.APP_VERSION = '3.3.0'; + stubMulti(); + stageMulti('2.0.0', 1); + await expect(svc.install('multi', { acceptTrekRange: true })).resolves.toEqual({ + id: 'multi', + version: '2.0.0', + trekRangeBypassed: null, + }); + expect(await findRow(t, Plugins, { id: 'multi' })).toMatchObject({ trek_range_accepted_version: null }); + }); + + it("an admin's install-anyway covers the plugin they asked for, never a dependency", async () => { + process.env.APP_VERSION = '4.0.0'; + stubMulti(); + await expect(svc.resolveVersion('multi', undefined, false)).rejects.toMatchObject({ + code: 'TREK_VERSION_INCOMPATIBLE', + }); + await expect(svc.resolveVersion('multi', undefined, true)).resolves.toMatchObject({ version: '2.1.0' }); + }); + it('honours an EXCLUSIVE upper bound, which minTrekVersion alone cannot express', async () => { // Both versions declare "<4.0.0" and neither carries a maxTrekVersion — under the old // min-only check TREK 4 looked compatible with everything. @@ -1223,7 +1260,7 @@ describe('TREK-version gating on install', () => { await expect(svc.install('multi')).resolves.toEqual({ id: 'multi', version: '2.1.0', - trekRangeBypassed: { trekRange: '>=3.4.0 <4.0.0', hostVersion: '3.3.0' }, + trekRangeBypassed: { trekRange: '>=3.4.0 <4.0.0', hostVersion: '3.3.0', acceptedByAdmin: false }, }); expect(warn.mock.calls.some((c) => /multi.*>=3\.4\.0 <4\.0\.0.*3\.3\.0/.test(String(c[0])))).toBe(true); warn.mockRestore(); @@ -1235,7 +1272,7 @@ describe('TREK-version gating on install', () => { stageMulti('2.1.0', 0); await expect(svc.install('multi', { version: '2.1.0' })).resolves.toMatchObject({ version: '2.1.0', - trekRangeBypassed: { trekRange: '>=3.4.0 <4.0.0', hostVersion: '3.3.0' }, + trekRangeBypassed: { trekRange: '>=3.4.0 <4.0.0', hostVersion: '3.3.0', acceptedByAdmin: false }, }); }); @@ -1268,7 +1305,7 @@ describe('TREK-version gating on install', () => { safeDownload.mockResolvedValue({ bytes: artifact, sha256: sha }); await expect(svc.install('flight-tracker')).resolves.toMatchObject({ id: 'flight-tracker', - trekRangeBypassed: { trekRange: '>=3.2.0 <3.4.0', hostVersion: '3.9.0' }, + trekRangeBypassed: { trekRange: '>=3.2.0 <3.4.0', hostVersion: '3.9.0', acceptedByAdmin: false }, }); expect(fs.existsSync(path.join(codeRoot, 'flight-tracker'))).toBe(true); }); @@ -1287,7 +1324,7 @@ describe('TREK-version gating on install', () => { REGISTRY.plugins[0].versions[0].sha256 = sha; safeDownload.mockResolvedValue({ bytes: artifact, sha256: sha }); await expect(svc.install('flight-tracker')).resolves.toMatchObject({ - trekRangeBypassed: { trekRange: null, hostVersion: '3.9.0' }, + trekRangeBypassed: { trekRange: null, hostVersion: '3.9.0', acceptedByAdmin: false }, }); }); @@ -1298,13 +1335,15 @@ describe('TREK-version gating on install', () => { ); expect(old).toMatchObject({ id: 'my-upload', - trekRangeBypassed: { trekRange: '>=3.0.0 <3.5.0', hostVersion: '3.9.0' }, + trekRangeBypassed: { trekRange: '>=3.0.0 <3.5.0', hostVersion: '3.9.0', acceptedByAdmin: false }, }); fs.rmSync(old.stagingDir, { recursive: true, force: true }); const rangeless = svc.stageUpload( makeArtifact({ id: 'my-upload', name: 'Up', version: '1.0.0', type: 'integration', trek: undefined }), ); - expect(rangeless).toMatchObject({ trekRangeBypassed: { trekRange: null, hostVersion: '3.9.0' } }); + expect(rangeless).toMatchObject({ + trekRangeBypassed: { trekRange: null, hostVersion: '3.9.0', acceptedByAdmin: false }, + }); fs.rmSync(rangeless.stagingDir, { recursive: true, force: true }); const fits = svc.stageUpload( makeArtifact({ id: 'my-upload', name: 'Up', version: '1.0.0', type: 'integration' }), diff --git a/server/tests/unit/db/legacy-baseline.test.ts b/server/tests/unit/db/legacy-baseline.test.ts index ac0acfbf3..2d7c3b955 100644 --- a/server/tests/unit/db/legacy-baseline.test.ts +++ b/server/tests/unit/db/legacy-baseline.test.ts @@ -98,6 +98,7 @@ const UNNUMBERED = [ 'Migration20200101046280_an_assistant_conversation_can_be_about_no_one_trip', 'Migration20200101046290_a_removed_member_cannot_rejoin_by_invite_link', 'Migration20200101046300_an_attached_file_stays_with_its_conversation', + 'Migration20200101046310_an_admin_can_run_a_plugin_outside_its_trek_range', ]; // The last step the positional runner ever had; planLegacyBaseline refuses a // schema_version past it. diff --git a/server/tests/unit/plugins/host-compat.test.ts b/server/tests/unit/plugins/host-compat.test.ts index 5efa7c2b0..019cf20aa 100644 --- a/server/tests/unit/plugins/host-compat.test.ts +++ b/server/tests/unit/plugins/host-compat.test.ts @@ -6,6 +6,7 @@ import { hostSatisfies, trekRangeBypassed, bypassedRange, + acceptanceHolds, __resetHostWarningForTests, } from '../../../src/nest/plugins/install/host-compat'; @@ -125,6 +126,27 @@ describe('hostSatisfies', () => { * the truth (hostSatisfies) never changes — the UI still has to say the plugin is * outside its range. */ +describe('acceptanceHolds', () => { + it('holds for the same plugin version on the same TREK major only', () => { + process.env.APP_VERSION = '5.0.1'; + const given = { acceptedVersion: '1.0.3', acceptedHost: '5.0.0' }; + expect(acceptanceHolds('1.0.3', given)).toBe(true); + expect(acceptanceHolds('1.1.0', given)).toBe(false); // an update asks again + process.env.APP_VERSION = '6.0.0'; + expect(acceptanceHolds('1.0.3', given)).toBe(false); // so does a new TREK major + }); + + it('holds nothing without a full consent or a version to compare', () => { + process.env.APP_VERSION = '5.0.0'; + expect(acceptanceHolds('1.0.0', null)).toBe(false); + expect(acceptanceHolds('1.0.0', { acceptedVersion: '1.0.0', acceptedHost: null })).toBe(false); + expect(acceptanceHolds(null, { acceptedVersion: '1.0.0', acceptedHost: '5.0.0' })).toBe(false); + expect(acceptanceHolds('1.0.0', { acceptedVersion: '1.0.0', acceptedHost: 'not-a-version' })).toBe(false); + process.env.APP_VERSION = 'dev'; + expect(acceptanceHolds('1.0.0', { acceptedVersion: '1.0.0', acceptedHost: '5.0.0' })).toBe(false); + }); +}); + describe('range bypass (TREK_PLUGINS_IGNORE_TREK_RANGE)', () => { it('is off by default and only an explicit truthy value turns it on', () => { expect(trekRangeBypassed()).toBe(false); @@ -138,15 +160,27 @@ describe('range bypass (TREK_PLUGINS_IGNORE_TREK_RANGE)', () => { process.env.APP_VERSION = '3.3.0'; expect(bypassedRange('>=4.0.0')).toBeNull(); // off → the gate refuses, nothing to report process.env.TREK_PLUGINS_IGNORE_TREK_RANGE = 'true'; - expect(bypassedRange('>=4.0.0')).toEqual({ trekRange: '>=4.0.0', hostVersion: '3.3.0' }); + expect(bypassedRange('>=4.0.0')).toEqual({ trekRange: '>=4.0.0', hostVersion: '3.3.0', acceptedByAdmin: false }); expect(bypassedRange('>=3.2.0 <4.0.0')).toBeNull(); // fits → not a bypass, a normal install }); it('covers a plugin that declared no range at all — the same author oversight', () => { process.env.APP_VERSION = '3.3.0'; process.env.TREK_PLUGINS_IGNORE_TREK_RANGE = '1'; - expect(bypassedRange(null)).toEqual({ trekRange: null, hostVersion: '3.3.0' }); - expect(bypassedRange(undefined)).toEqual({ trekRange: null, hostVersion: '3.3.0' }); + expect(bypassedRange(null)).toEqual({ trekRange: null, hostVersion: '3.3.0', acceptedByAdmin: false }); + expect(bypassedRange(undefined)).toEqual({ trekRange: null, hostVersion: '3.3.0', acceptedByAdmin: false }); + }); + + it("an admin's consent for one plugin lets it through without the switch, and says it was theirs", () => { + process.env.APP_VERSION = '5.0.0'; + expect(bypassedRange('>=3.2.0 <4.0.0', true)).toEqual({ + trekRange: '>=3.2.0 <4.0.0', + hostVersion: '5.0.0', + acceptedByAdmin: true, + }); + expect(bypassedRange('>=5.0.0', true)).toBeNull(); // fits: nothing was set aside + process.env.TREK_PLUGINS_IGNORE_TREK_RANGE = '1'; + expect(bypassedRange('>=3.2.0 <4.0.0', true)).toMatchObject({ acceptedByAdmin: false }); }); it('leaves hostSatisfies telling the truth while bypassed', () => { diff --git a/server/tests/unit/plugins/plugins-service.test.ts b/server/tests/unit/plugins/plugins-service.test.ts index 92b2159f5..0e25ce27d 100644 --- a/server/tests/unit/plugins/plugins-service.test.ts +++ b/server/tests/unit/plugins/plugins-service.test.ts @@ -134,10 +134,32 @@ describe('PluginsService.list', () => { expect(out.ignoreTrekRange).toBe(true); expect(out.plugins[0]).toMatchObject({ dependencyStatus: 'ok', - trekRangeBypassed: { trekRange: '>=3.0.0 <4.0.0', hostVersion: '4.1.0' }, + trekRangeBypassed: { trekRange: '>=3.0.0 <4.0.0', hostVersion: '4.1.0', acceptedByAdmin: false }, }); }); + it("an admin's consent for this version lets the outgrown plugin activate and names it as theirs", async () => { + process.env.APP_VERSION = '4.1.0'; + await insertRow(orm, Plugins, { + id: 'old', + name: 'Old', + type: 'widget', + status: 'inactive', + version: '1.0.0', + trek_range: '>=3.0.0 <4.0.0', + trek_range_accepted_version: '1.0.0', + trek_range_accepted_host: '4.0.0', + }); + const out = await (await makeService()).list(); + expect(out.ignoreTrekRange).toBe(false); + expect(out.plugins[0]).toMatchObject({ + dependencyStatus: 'ok', + trekRangeBypassed: { trekRange: '>=3.0.0 <4.0.0', hostVersion: '4.1.0', acceptedByAdmin: true }, + }); + // The consent stays on the server: the list does not hand its columns out. + expect(out.plugins[0]).not.toHaveProperty('trek_range_accepted_version'); + }); + it('a plugin inside its range carries no marker even with the switch on', async () => { process.env.APP_VERSION = '3.5.0'; process.env.TREK_PLUGINS_IGNORE_TREK_RANGE = '1'; diff --git a/shared/src/i18n/ar/admin.ts b/shared/src/i18n/ar/admin.ts index b526afd14..ddf3dec3a 100644 --- a/shared/src/i18n/ar/admin.ts +++ b/shared/src/i18n/ar/admin.ts @@ -646,7 +646,17 @@ const admin: TranslationStrings = { 'لا تعلن «{name}» أي إصدارات TREK تدعمها؛ يعمل هذا الخادم بالإصدار {host}. يسمح TREK بذلك فقط لأن TREK_PLUGINS_IGNORE_TREK_RANGE مضبوط. لا شيء يدل على أن المطوّر اختبرها على هذا الإصدار من TREK، لذا لا يوجد ضمان أنها ستعمل — وفي حالات نادرة قد تُتلف إضافة غير متوافقة بيانات TREK. تابع فقط إذا كنت تقبل هذه المخاطرة.', 'admin.plugins.dep.trekBypassed': 'خارج نطاق TREK الخاص بها ({range}) — فحوصات الإصدار متوقفة', 'admin.plugins.dep.trekBypassedUnknown': 'لا تعلن أي نطاق TREK — فحوصات الإصدار متوقفة', - 'admin.plugins.incompatible': 'غير متوافق', + 'admin.plugins.rangeBypass.titleAdmin': 'غير مختبَر على هذا الإصدار من TREK', + 'admin.plugins.rangeBypass.bodyAdmin': + 'بحسب مطوّره، يدعم «{name}» إصدارات TREK {range}، بينما يعمل هذا الخادم بالإصدار {host}. لم يؤكد المطوّر توافقه مع هذا الإصدار بعد، لذا قد لا يعمل، وفي حالات نادرة قد تتلف إضافة غير متوافقة بيانات TREK. يمكنك استخدامها رغم ذلك: يسري اختيارك على هذه الإضافة في هذا الإصدار فقط، وسيسألك TREK مجددًا بعد أي تحديث.', + 'admin.plugins.rangeBypass.bodyAdminUnknown': + 'لا يذكر «{name}» إصدارات TREK التي يدعمها، بينما يعمل هذا الخادم بالإصدار {host}. لا شيء يشير إلى أنه اختُبر على هذا الإصدار، لذا قد لا يعمل، وفي حالات نادرة قد تتلف إضافة غير متوافقة بيانات TREK. يمكنك استخدامها رغم ذلك: يسري اختيارك على هذه الإضافة في هذا الإصدار فقط، وسيسألك TREK مجددًا بعد أي تحديث.', + 'admin.plugins.dep.trekAccepted': 'مصمَّمة لـ TREK {range}، تعمل على مسؤوليتك', + 'admin.plugins.dep.trekAcceptedUnknown': 'لا يوجد إصدار TREK محدد، تعمل على مسؤوليتك', + 'admin.plugins.activateAnyway': 'تفعيل رغم ذلك', + 'admin.plugins.untestedChip': 'لم يُؤكَّد توافقه مع TREK {host} بعد', + 'admin.plugins.filters': 'تصفية', + 'admin.plugins.security.short': 'كيف تُحمى الإضافات', 'admin.plugins.accessTitle': 'ما الذي يمكنها الوصول إليه', 'admin.plugins.connectsTitle': 'يتصل بـ', 'admin.plugins.detailsTitle': 'التفاصيل', diff --git a/shared/src/i18n/az/admin.ts b/shared/src/i18n/az/admin.ts index 7f64184b1..1430bb077 100644 --- a/shared/src/i18n/az/admin.ts +++ b/shared/src/i18n/az/admin.ts @@ -683,7 +683,17 @@ const admin: TranslationStrings = { '“{name}” hansı TREK versiyalarını dəstəklədiyini göstərmir; bu serverdə {host} işləyir. TREK buna yalnız TREK_PLUGINS_IGNORE_TREK_RANGE təyin edildiyi üçün icazə verir. Müəllifin onu bu TREK versiyasında sınaqdan keçirdiyinə dair məlumat yoxdur, buna görə işləyəcəyinə zəmanət verilmir — nadir hallarda uyğun olmayan plagin TREK məlumatlarını korlaya bilər. Yalnız bu riski qəbul edirsinizsə davam edin.', 'admin.plugins.dep.trekBypassed': 'TREK versiya aralığından ({range}) kənardır — versiya yoxlamaları deaktivdir', 'admin.plugins.dep.trekBypassedUnknown': 'TREK versiya aralığı göstərilməyib — versiya yoxlamaları deaktivdir', - 'admin.plugins.incompatible': 'Uyğun deyil', + 'admin.plugins.rangeBypass.titleAdmin': 'Bu TREK-də sınaqdan keçməyib', + 'admin.plugins.rangeBypass.bodyAdmin': + 'Müəllifinə görə “{name}” TREK {range} versiyalarını dəstəkləyir, bu server isə {host} işlədir. Müəllif onu bu TREK üçün hələ təsdiqləməyib, ona görə də işləməyə bilər və nadir hallarda uyğun olmayan plagin TREK məlumatlarına zərər verə bilər. Yenə də istifadə edə bilərsən: seçimin yalnız bu versiyadakı bu plagin üçün keçərlidir, yeniləmədən sonra TREK yenidən soruşacaq.', + 'admin.plugins.rangeBypass.bodyAdminUnknown': + '“{name}” hansı TREK versiyalarını dəstəklədiyini göstərmir, bu server isə {host} işlədir. Onun bu TREK-də sınaqdan keçdiyinə dair heç bir işarə yoxdur, ona görə də işləməyə bilər və nadir hallarda uyğun olmayan plagin TREK məlumatlarına zərər verə bilər. Yenə də istifadə edə bilərsən: seçimin yalnız bu versiyadakı bu plagin üçün keçərlidir, yeniləmədən sonra TREK yenidən soruşacaq.', + 'admin.plugins.dep.trekAccepted': 'TREK {range} üçün hazırlanıb, öz riskinlə işləyir', + 'admin.plugins.dep.trekAcceptedUnknown': 'TREK versiyası göstərilməyib, öz riskinlə işləyir', + 'admin.plugins.activateAnyway': 'Yenə də aktivləşdir', + 'admin.plugins.untestedChip': 'TREK {host} üçün hələ təsdiqlənməyib', + 'admin.plugins.filters': 'Filtr', + 'admin.plugins.security.short': 'Plaginlər necə qorunur', 'admin.plugins.accessTitle': 'Giriş əldə edə biləcəyi məlumatlar', 'admin.plugins.connectsTitle': 'Qoşulduğu xidmətlər', 'admin.plugins.detailsTitle': 'Ətraflı məlumat', diff --git a/shared/src/i18n/br/admin.ts b/shared/src/i18n/br/admin.ts index ace5e1a95..9af1ca691 100644 --- a/shared/src/i18n/br/admin.ts +++ b/shared/src/i18n/br/admin.ts @@ -593,7 +593,17 @@ const admin: TranslationStrings = { '“{name}” não declara quais versões do TREK suporta; este servidor executa {host}. O TREK só o deixa passar porque TREK_PLUGINS_IGNORE_TREK_RANGE está definido. Nada indica que o autor o tenha testado neste TREK, então não há garantia de que funcione — e, em casos raros, um plugin incompatível pode corromper os dados do TREK. Continue apenas se aceitar esse risco.', 'admin.plugins.dep.trekBypassed': 'Fora do seu intervalo TREK ({range}) — verificações de versão desligadas', 'admin.plugins.dep.trekBypassedUnknown': 'Não declara nenhum intervalo TREK — verificações de versão desligadas', - 'admin.plugins.incompatible': 'Incompatível', + 'admin.plugins.rangeBypass.titleAdmin': 'Não testado neste TREK', + 'admin.plugins.rangeBypass.bodyAdmin': + 'Segundo o autor, “{name}” é compatível com o TREK {range}, e este servidor roda o {host}. O autor ainda não o confirmou para este TREK, então ele pode não funcionar e, em casos raros, um plugin incompatível pode danificar os dados do TREK. Você ainda pode usá-lo: sua escolha vale só para este plugin nesta versão, e o TREK pergunta de novo depois de uma atualização.', + 'admin.plugins.rangeBypass.bodyAdminUnknown': + '“{name}” não informa com quais versões do TREK é compatível, e este servidor roda o {host}. Nada indica que ele foi testado neste TREK, então ele pode não funcionar e, em casos raros, um plugin incompatível pode danificar os dados do TREK. Você ainda pode usá-lo: sua escolha vale só para este plugin nesta versão, e o TREK pergunta de novo depois de uma atualização.', + 'admin.plugins.dep.trekAccepted': 'Feito para o TREK {range}, em uso por sua conta e risco', + 'admin.plugins.dep.trekAcceptedUnknown': 'Sem versão do TREK informada, em uso por sua conta e risco', + 'admin.plugins.activateAnyway': 'Ativar mesmo assim', + 'admin.plugins.untestedChip': 'Ainda não confirmado para o TREK {host}', + 'admin.plugins.filters': 'Filtrar', + 'admin.plugins.security.short': 'Como os plugins são protegidos', 'admin.plugins.accessTitle': 'O que ele pode acessar', 'admin.plugins.connectsTitle': 'Conecta-se a', 'admin.plugins.detailsTitle': 'Detalhes', diff --git a/shared/src/i18n/ca/admin.ts b/shared/src/i18n/ca/admin.ts index 77f28b689..79d70aad9 100644 --- a/shared/src/i18n/ca/admin.ts +++ b/shared/src/i18n/ca/admin.ts @@ -872,7 +872,17 @@ const admin: TranslationStrings = { '«{name}» no declara quines versions de TREK admet; aquest servidor executa {host}. TREK el deixa passar només perquè TREK_PLUGINS_IGNORE_TREK_RANGE està definit. Res no indica que l’autor l’hagi provat en aquest TREK, així que no hi ha cap garantia que funcioni — i en casos rars un connector incompatible pot corrompre les dades de TREK. Continua només si acceptes aquest risc.', 'admin.plugins.dep.trekBypassed': 'Fora del seu rang de TREK ({range}) — comprovacions de versió desactivades', 'admin.plugins.dep.trekBypassedUnknown': 'No declara cap rang de TREK — comprovacions de versió desactivades', - 'admin.plugins.incompatible': 'Incompatible', // same-as-en + 'admin.plugins.rangeBypass.titleAdmin': 'Sense provar en aquest TREK', + 'admin.plugins.rangeBypass.bodyAdmin': + 'Segons el seu autor, «{name}» és compatible amb TREK {range}, i aquest servidor executa {host}. L’autor encara no l’ha confirmat per a aquest TREK, així que pot no funcionar i, en casos rars, un plugin que no encaixa pot malmetre les dades de TREK. Igualment el pots fer servir: la teva decisió val només per a aquest plugin en aquesta versió, i TREK tornarà a preguntar després d’una actualització.', + 'admin.plugins.rangeBypass.bodyAdminUnknown': + '«{name}» no indica quines versions de TREK admet, i aquest servidor executa {host}. Res no indica que s’hagi provat en aquest TREK, així que pot no funcionar i, en casos rars, un plugin que no encaixa pot malmetre les dades de TREK. Igualment el pots fer servir: la teva decisió val només per a aquest plugin en aquesta versió, i TREK tornarà a preguntar després d’una actualització.', + 'admin.plugins.dep.trekAccepted': 'Fet per a TREK {range}, en ús sota la teva responsabilitat', + 'admin.plugins.dep.trekAcceptedUnknown': 'Sense versió de TREK indicada, en ús sota la teva responsabilitat', + 'admin.plugins.activateAnyway': 'Activa igualment', + 'admin.plugins.untestedChip': 'Encara no confirmat per a TREK {host}', + 'admin.plugins.filters': 'Filtra', + 'admin.plugins.security.short': 'Com es protegeixen els plugins', 'admin.plugins.accessTitle': 'A què pot accedir', 'admin.plugins.connectsTitle': 'Es connecta a', 'admin.plugins.detailsTitle': 'Detalls', diff --git a/shared/src/i18n/cs/admin.ts b/shared/src/i18n/cs/admin.ts index cd5e8b39f..ffd405233 100644 --- a/shared/src/i18n/cs/admin.ts +++ b/shared/src/i18n/cs/admin.ts @@ -596,7 +596,17 @@ const admin: TranslationStrings = { '„{name}“ neuvádí, které verze TREKu podporuje; tento server běží na {host}. TREK ho propouští jen proto, že je nastaveno TREK_PLUGINS_IGNORE_TREK_RANGE. Nic nenasvědčuje tomu, že ho autor na tomto TREKu testoval, takže není zaručeno, že bude fungovat — a ve vzácných případech může nekompatibilní plugin poškodit data TREKu. Pokračujte jen tehdy, pokud toto riziko přijímáte.', 'admin.plugins.dep.trekBypassed': 'Mimo svůj rozsah TREKu ({range}) — kontroly verzí vypnuty', 'admin.plugins.dep.trekBypassedUnknown': 'Neuvádí žádný rozsah TREKu — kontroly verzí vypnuty', - 'admin.plugins.incompatible': 'Nekompatibilní', + 'admin.plugins.rangeBypass.titleAdmin': 'Na tomto TREK netestováno', + 'admin.plugins.rangeBypass.bodyAdmin': + 'Podle autora „{name}“ podporuje TREK {range} a tento server běží na {host}. Autor ho pro tento TREK zatím nepotvrdil, takže nemusí fungovat a ve vzácných případech může nevhodný plugin poškodit data TREK. Přesto ho můžete používat: vaše rozhodnutí platí jen pro tento plugin v této verzi a po aktualizaci se TREK zeptá znovu.', + 'admin.plugins.rangeBypass.bodyAdminUnknown': + '„{name}“ neuvádí, které verze TREK podporuje, a tento server běží na {host}. Nic nenasvědčuje tomu, že byl na tomto TREK testován, takže nemusí fungovat a ve vzácných případech může nevhodný plugin poškodit data TREK. Přesto ho můžete používat: vaše rozhodnutí platí jen pro tento plugin v této verzi a po aktualizaci se TREK zeptá znovu.', + 'admin.plugins.dep.trekAccepted': 'Vytvořeno pro TREK {range}, běží na vlastní riziko', + 'admin.plugins.dep.trekAcceptedUnknown': 'Bez uvedené verze TREK, běží na vlastní riziko', + 'admin.plugins.activateAnyway': 'Přesto aktivovat', + 'admin.plugins.untestedChip': 'Pro TREK {host} zatím nepotvrzeno', + 'admin.plugins.filters': 'Filtr', + 'admin.plugins.security.short': 'Jak jsou pluginy zabezpečené', 'admin.plugins.accessTitle': 'K čemu má přístup', 'admin.plugins.connectsTitle': 'Připojuje se k', 'admin.plugins.detailsTitle': 'Podrobnosti', diff --git a/shared/src/i18n/de/admin.ts b/shared/src/i18n/de/admin.ts index 174fa7c2e..56eff7ec2 100644 --- a/shared/src/i18n/de/admin.ts +++ b/shared/src/i18n/de/admin.ts @@ -600,7 +600,17 @@ const admin: TranslationStrings = { '„{name}“ gibt nicht an, welche TREK-Versionen es unterstützt; dieser Server läuft mit {host}. TREK lässt es nur durch, weil TREK_PLUGINS_IGNORE_TREK_RANGE gesetzt ist. Nichts belegt, dass der Autor es auf diesem TREK getestet hat, es gibt also keine Garantie, dass es funktioniert — und in seltenen Fällen kann ein unpassendes Plugin TREK-Daten beschädigen. Fahre nur fort, wenn du dieses Risiko akzeptierst.', 'admin.plugins.dep.trekBypassed': 'Außerhalb seines TREK-Bereichs ({range}) — Versionsprüfung aus', 'admin.plugins.dep.trekBypassedUnknown': 'Gibt keinen TREK-Bereich an — Versionsprüfung aus', - 'admin.plugins.incompatible': 'Nicht kompatibel', + 'admin.plugins.rangeBypass.titleAdmin': 'Ungeprüft auf diesem TREK', + 'admin.plugins.rangeBypass.bodyAdmin': + '„{name}“ unterstützt laut Autor TREK {range}, dieser Server läuft mit {host}. Für dieses TREK hat der Autor es noch nicht freigegeben. Es kann also sein, dass es nicht funktioniert, und in seltenen Fällen kann ein unpassendes Plugin TREK-Daten beschädigen. Du kannst es trotzdem nutzen: Deine Entscheidung gilt nur für dieses Plugin in dieser Version, nach einem Update fragt TREK erneut.', + 'admin.plugins.rangeBypass.bodyAdminUnknown': + '„{name}“ gibt nicht an, welche TREK-Versionen es unterstützt, dieser Server läuft mit {host}. Nichts deutet darauf hin, dass es auf diesem TREK getestet wurde. Es kann also sein, dass es nicht funktioniert, und in seltenen Fällen kann ein unpassendes Plugin TREK-Daten beschädigen. Du kannst es trotzdem nutzen: Deine Entscheidung gilt nur für dieses Plugin in dieser Version, nach einem Update fragt TREK erneut.', + 'admin.plugins.dep.trekAccepted': 'Für TREK {range} gebaut, läuft auf eigenes Risiko', + 'admin.plugins.dep.trekAcceptedUnknown': 'Keine TREK-Version angegeben, läuft auf eigenes Risiko', + 'admin.plugins.activateAnyway': 'Trotzdem aktivieren', + 'admin.plugins.untestedChip': 'Noch nicht für TREK {host} freigegeben', + 'admin.plugins.filters': 'Filter', + 'admin.plugins.security.short': 'So sind Plugins abgesichert', 'admin.plugins.accessTitle': 'Worauf es zugreift', 'admin.plugins.connectsTitle': 'Verbindet sich mit', 'admin.plugins.detailsTitle': 'Details', // same-as-en diff --git a/shared/src/i18n/en/admin.ts b/shared/src/i18n/en/admin.ts index 7c526f831..eac3d2e51 100644 --- a/shared/src/i18n/en/admin.ts +++ b/shared/src/i18n/en/admin.ts @@ -646,7 +646,17 @@ const admin: TranslationStrings = { '“{name}” does not declare which TREK versions it supports; this server runs {host}. TREK lets it through only because TREK_PLUGINS_IGNORE_TREK_RANGE is set. Nothing says its author tested it on this TREK, so there is no guarantee it works — and in rare cases a mismatched plugin can corrupt TREK data. Continue only if you accept that risk.', 'admin.plugins.dep.trekBypassed': 'Outside its TREK range ({range}) — version checks off', 'admin.plugins.dep.trekBypassedUnknown': 'Declares no TREK range — version checks off', - 'admin.plugins.incompatible': 'Incompatible', + 'admin.plugins.rangeBypass.titleAdmin': 'Untested on this TREK', + 'admin.plugins.rangeBypass.bodyAdmin': + '“{name}” supports TREK {range} according to its author, and this server runs {host}. The author has not confirmed it for this TREK yet, so it may not work, and in rare cases a plugin that does not fit can damage TREK data. You can still use it: your choice applies to this plugin in this version only, and TREK asks again after an update.', + 'admin.plugins.rangeBypass.bodyAdminUnknown': + '“{name}” does not say which TREK versions it supports, and this server runs {host}. Nothing says it was tested on this TREK, so it may not work, and in rare cases a plugin that does not fit can damage TREK data. You can still use it: your choice applies to this plugin in this version only, and TREK asks again after an update.', + 'admin.plugins.dep.trekAccepted': 'Made for TREK {range}, running at your own risk', + 'admin.plugins.dep.trekAcceptedUnknown': 'No TREK range declared, running at your own risk', + 'admin.plugins.activateAnyway': 'Activate anyway', + 'admin.plugins.untestedChip': 'Not confirmed for TREK {host} yet', + 'admin.plugins.filters': 'Filter', + 'admin.plugins.security.short': 'How plugins are secured', 'admin.plugins.accessTitle': 'What it can access', 'admin.plugins.connectsTitle': 'Connects to', 'admin.plugins.detailsTitle': 'Details', diff --git a/shared/src/i18n/es/admin.ts b/shared/src/i18n/es/admin.ts index e931816a9..ad5275d4e 100644 --- a/shared/src/i18n/es/admin.ts +++ b/shared/src/i18n/es/admin.ts @@ -602,7 +602,17 @@ const admin: TranslationStrings = { '«{name}» no declara qué versiones de TREK admite; este servidor ejecuta {host}. TREK lo deja pasar solo porque TREK_PLUGINS_IGNORE_TREK_RANGE está definido. Nada indica que su autor lo haya probado en este TREK, así que no hay garantía de que funcione — y en casos raros un plugin incompatible puede corromper los datos de TREK. Continúa solo si aceptas ese riesgo.', 'admin.plugins.dep.trekBypassed': 'Fuera de su rango de TREK ({range}) — comprobaciones de versión desactivadas', 'admin.plugins.dep.trekBypassedUnknown': 'No declara ningún rango de TREK — comprobaciones de versión desactivadas', - 'admin.plugins.incompatible': 'Incompatible', // same-as-en + 'admin.plugins.rangeBypass.titleAdmin': 'Sin probar en este TREK', + 'admin.plugins.rangeBypass.bodyAdmin': + 'Según su autor, «{name}» es compatible con TREK {range}, y este servidor ejecuta {host}. El autor aún no lo ha confirmado para este TREK, así que puede no funcionar y, en casos raros, un plugin que no encaja puede dañar los datos de TREK. Aun así puedes usarlo: tu decisión vale solo para este plugin en esta versión, y TREK volverá a preguntar tras una actualización.', + 'admin.plugins.rangeBypass.bodyAdminUnknown': + '«{name}» no indica qué versiones de TREK admite, y este servidor ejecuta {host}. Nada indica que se haya probado en este TREK, así que puede no funcionar y, en casos raros, un plugin que no encaja puede dañar los datos de TREK. Aun así puedes usarlo: tu decisión vale solo para este plugin en esta versión, y TREK volverá a preguntar tras una actualización.', + 'admin.plugins.dep.trekAccepted': 'Hecho para TREK {range}, en uso bajo tu responsabilidad', + 'admin.plugins.dep.trekAcceptedUnknown': 'Sin versión de TREK indicada, en uso bajo tu responsabilidad', + 'admin.plugins.activateAnyway': 'Activar de todos modos', + 'admin.plugins.untestedChip': 'Aún no confirmado para TREK {host}', + 'admin.plugins.filters': 'Filtrar', + 'admin.plugins.security.short': 'Cómo se protegen los plugins', 'admin.plugins.accessTitle': 'A qué puede acceder', 'admin.plugins.connectsTitle': 'Se conecta a', 'admin.plugins.detailsTitle': 'Detalles', diff --git a/shared/src/i18n/et/admin.ts b/shared/src/i18n/et/admin.ts index dedae4702..990c19694 100644 --- a/shared/src/i18n/et/admin.ts +++ b/shared/src/i18n/et/admin.ts @@ -664,7 +664,17 @@ const admin: TranslationStrings = { '„{name}” ei deklareeri, milliseid TREKi versioone see toetab; selles serveris töötab {host}. TREK lubab selle ainult seetõttu, et TREK_PLUGINS_IGNORE_TREK_RANGE on määratud. Miski ei kinnita, et autor oleks seda selle TREKiga testinud, seega pole toimivus tagatud — harvadel juhtudel võib ühildumatu plugin TREKi andmeid rikkuda. Jätka ainult siis, kui nõustud selle riskiga.', 'admin.plugins.dep.trekBypassed': 'Väljaspool TREKi versioonivahemikku ({range}) — versioonikontrollid väljas', 'admin.plugins.dep.trekBypassedUnknown': 'TREKi versioonivahemik deklareerimata — versioonikontrollid väljas', - 'admin.plugins.incompatible': 'Ühildumatu', + 'admin.plugins.rangeBypass.titleAdmin': 'Selles TREK-is testimata', + 'admin.plugins.rangeBypass.bodyAdmin': + 'Autori sõnul toetab „{name}“ TREK-i {range}, see server töötab aga versioonil {host}. Autor pole seda selle TREK-i jaoks veel kinnitanud, nii et see ei pruugi töötada ja harvadel juhtudel võib sobimatu plugin TREK-i andmeid kahjustada. Võid seda siiski kasutada: sinu valik kehtib ainult selle plugina selle versiooni kohta ja pärast uuendust küsib TREK uuesti.', + 'admin.plugins.rangeBypass.bodyAdminUnknown': + '„{name}“ ei ütle, milliseid TREK-i versioone see toetab, see server töötab aga versioonil {host}. Miski ei viita, et seda oleks selles TREK-is testitud, nii et see ei pruugi töötada ja harvadel juhtudel võib sobimatu plugin TREK-i andmeid kahjustada. Võid seda siiski kasutada: sinu valik kehtib ainult selle plugina selle versiooni kohta ja pärast uuendust küsib TREK uuesti.', + 'admin.plugins.dep.trekAccepted': 'Tehtud TREK-i {range} jaoks, töötab omal vastutusel', + 'admin.plugins.dep.trekAcceptedUnknown': 'TREK-i versioon puudub, töötab omal vastutusel', + 'admin.plugins.activateAnyway': 'Aktiveeri siiski', + 'admin.plugins.untestedChip': 'TREK {host} jaoks veel kinnitamata', + 'admin.plugins.filters': 'Filter', + 'admin.plugins.security.short': 'Nii on pluginad kaitstud', 'admin.plugins.accessTitle': 'Millele see ligi pääseb', 'admin.plugins.connectsTitle': 'Ühendub', 'admin.plugins.detailsTitle': 'Üksikasjad', diff --git a/shared/src/i18n/fr/admin.ts b/shared/src/i18n/fr/admin.ts index dd35664a6..fac810797 100644 --- a/shared/src/i18n/fr/admin.ts +++ b/shared/src/i18n/fr/admin.ts @@ -606,7 +606,17 @@ const admin: TranslationStrings = { '« {name} » n’indique pas quelles versions de TREK il prend en charge ; ce serveur exécute {host}. TREK le laisse passer uniquement parce que TREK_PLUGINS_IGNORE_TREK_RANGE est défini. Rien n’indique que son auteur l’a testé sur ce TREK : rien ne garantit qu’il fonctionne — et dans de rares cas, un plugin incompatible peut corrompre les données de TREK. Ne continuez que si vous acceptez ce risque.', 'admin.plugins.dep.trekBypassed': 'Hors de sa plage TREK ({range}) — contrôles de version désactivés', 'admin.plugins.dep.trekBypassedUnknown': 'Ne déclare aucune plage TREK — contrôles de version désactivés', - 'admin.plugins.incompatible': 'Incompatible', // same-as-en + 'admin.plugins.rangeBypass.titleAdmin': 'Non testé sur ce TREK', + 'admin.plugins.rangeBypass.bodyAdmin': + 'Selon son auteur, « {name} » prend en charge TREK {range}, et ce serveur exécute {host}. L’auteur ne l’a pas encore validé pour ce TREK : il peut ne pas fonctionner et, dans de rares cas, un plugin inadapté peut endommager les données de TREK. Vous pouvez quand même l’utiliser : votre choix ne vaut que pour ce plugin dans cette version, et TREK vous redemandera après une mise à jour.', + 'admin.plugins.rangeBypass.bodyAdminUnknown': + '« {name} » n’indique pas les versions de TREK qu’il prend en charge, et ce serveur exécute {host}. Rien n’indique qu’il a été testé sur ce TREK : il peut ne pas fonctionner et, dans de rares cas, un plugin inadapté peut endommager les données de TREK. Vous pouvez quand même l’utiliser : votre choix ne vaut que pour ce plugin dans cette version, et TREK vous redemandera après une mise à jour.', + 'admin.plugins.dep.trekAccepted': 'Conçu pour TREK {range}, utilisé à vos risques', + 'admin.plugins.dep.trekAcceptedUnknown': 'Aucune version de TREK indiquée, utilisé à vos risques', + 'admin.plugins.activateAnyway': 'Activer quand même', + 'admin.plugins.untestedChip': 'Pas encore validé pour TREK {host}', + 'admin.plugins.filters': 'Filtrer', + 'admin.plugins.security.short': 'Comment les plugins sont sécurisés', 'admin.plugins.accessTitle': 'Ce à quoi il peut accéder', 'admin.plugins.connectsTitle': 'Se connecte à', 'admin.plugins.detailsTitle': 'Détails', diff --git a/shared/src/i18n/gr/admin.ts b/shared/src/i18n/gr/admin.ts index 71f68ac78..0e4895f21 100644 --- a/shared/src/i18n/gr/admin.ts +++ b/shared/src/i18n/gr/admin.ts @@ -663,7 +663,17 @@ const admin: TranslationStrings = { 'Το «{name}» δεν δηλώνει ποιες εκδόσεις TREK υποστηρίζει· αυτός ο διακομιστής εκτελεί {host}. Το TREK το επιτρέπει μόνο επειδή είναι ορισμένο το TREK_PLUGINS_IGNORE_TREK_RANGE. Τίποτα δεν δείχνει ότι ο δημιουργός το δοκίμασε σε αυτό το TREK, οπότε δεν υπάρχει εγγύηση ότι λειτουργεί — και σε σπάνιες περιπτώσεις ένα ασύμβατο πρόσθετο μπορεί να καταστρέψει δεδομένα του TREK. Συνεχίστε μόνο αν αποδέχεστε αυτόν τον κίνδυνο.', 'admin.plugins.dep.trekBypassed': 'Εκτός του εύρους TREK του ({range}) — έλεγχοι έκδοσης απενεργοποιημένοι', 'admin.plugins.dep.trekBypassedUnknown': 'Δεν δηλώνει εύρος TREK — έλεγχοι έκδοσης απενεργοποιημένοι', - 'admin.plugins.incompatible': 'Μη συμβατό', + 'admin.plugins.rangeBypass.titleAdmin': 'Μη δοκιμασμένο σε αυτό το TREK', + 'admin.plugins.rangeBypass.bodyAdmin': + 'Σύμφωνα με τον δημιουργό του, το «{name}» υποστηρίζει το TREK {range}, και αυτός ο διακομιστής τρέχει το {host}. Ο δημιουργός δεν το έχει επιβεβαιώσει ακόμη για αυτό το TREK, οπότε μπορεί να μη λειτουργεί και, σε σπάνιες περιπτώσεις, ένα ακατάλληλο πρόσθετο μπορεί να βλάψει τα δεδομένα του TREK. Μπορείτε να το χρησιμοποιήσετε παρ’ όλα αυτά: η επιλογή σας ισχύει μόνο για αυτό το πρόσθετο σε αυτή την έκδοση, και μετά από μια ενημέρωση το TREK θα ρωτήσει ξανά.', + 'admin.plugins.rangeBypass.bodyAdminUnknown': + 'Το «{name}» δεν αναφέρει ποιες εκδόσεις του TREK υποστηρίζει, και αυτός ο διακομιστής τρέχει το {host}. Τίποτα δεν δείχνει ότι δοκιμάστηκε σε αυτό το TREK, οπότε μπορεί να μη λειτουργεί και, σε σπάνιες περιπτώσεις, ένα ακατάλληλο πρόσθετο μπορεί να βλάψει τα δεδομένα του TREK. Μπορείτε να το χρησιμοποιήσετε παρ’ όλα αυτά: η επιλογή σας ισχύει μόνο για αυτό το πρόσθετο σε αυτή την έκδοση, και μετά από μια ενημέρωση το TREK θα ρωτήσει ξανά.', + 'admin.plugins.dep.trekAccepted': 'Φτιαγμένο για TREK {range}, λειτουργεί με δική σας ευθύνη', + 'admin.plugins.dep.trekAcceptedUnknown': 'Χωρίς δηλωμένη έκδοση TREK, λειτουργεί με δική σας ευθύνη', + 'admin.plugins.activateAnyway': 'Ενεργοποίηση παρ’ όλα αυτά', + 'admin.plugins.untestedChip': 'Δεν έχει επιβεβαιωθεί ακόμη για το TREK {host}', + 'admin.plugins.filters': 'Φίλτρο', + 'admin.plugins.security.short': 'Πώς προστατεύονται τα πρόσθετα', 'admin.plugins.accessTitle': 'Σε τι έχει πρόσβαση', 'admin.plugins.connectsTitle': 'Συνδέεται σε', 'admin.plugins.detailsTitle': 'Λεπτομέρειες', diff --git a/shared/src/i18n/hu/admin.ts b/shared/src/i18n/hu/admin.ts index 18b3eee3b..299cc164b 100644 --- a/shared/src/i18n/hu/admin.ts +++ b/shared/src/i18n/hu/admin.ts @@ -605,7 +605,17 @@ const admin: TranslationStrings = { 'A(z) „{name}” nem adja meg, mely TREK-verziókat támogatja; ez a szerver {host}-t futtat. A TREK csak azért engedi át, mert a TREK_PLUGINS_IGNORE_TREK_RANGE be van állítva. Semmi sem jelzi, hogy a szerző ezen a TREK-en tesztelte volna, így nincs garancia, hogy működik — és ritka esetben egy nem illeszkedő bővítmény megsértheti a TREK adatait. Csak akkor folytasd, ha vállalod ezt a kockázatot.', 'admin.plugins.dep.trekBypassed': 'A TREK-tartományán kívül ({range}) — verzióellenőrzés kikapcsolva', 'admin.plugins.dep.trekBypassedUnknown': 'Nem ad meg TREK-tartományt — verzióellenőrzés kikapcsolva', - 'admin.plugins.incompatible': 'Nem kompatibilis', + 'admin.plugins.rangeBypass.titleAdmin': 'Ezen a TREK-en nem tesztelt', + 'admin.plugins.rangeBypass.bodyAdmin': + 'A szerzője szerint a(z) „{name}” a TREK {range} verziókat támogatja, ez a szerver pedig {host} verziót futtat. A szerző még nem erősítette meg ehhez a TREK-hez, ezért lehet, hogy nem működik, és ritkán egy nem illő plugin károsíthatja a TREK adatait. Ennek ellenére használhatod: a döntésed csak erre a pluginra és erre a verzióra vonatkozik, frissítés után a TREK újra rákérdez.', + 'admin.plugins.rangeBypass.bodyAdminUnknown': + 'A(z) „{name}” nem adja meg, mely TREK-verziókat támogatja, ez a szerver pedig {host} verziót futtat. Semmi nem utal arra, hogy ezen a TREK-en tesztelték, ezért lehet, hogy nem működik, és ritkán egy nem illő plugin károsíthatja a TREK adatait. Ennek ellenére használhatod: a döntésed csak erre a pluginra és erre a verzióra vonatkozik, frissítés után a TREK újra rákérdez.', + 'admin.plugins.dep.trekAccepted': 'TREK {range} verzióhoz készült, saját felelősségre fut', + 'admin.plugins.dep.trekAcceptedUnknown': 'Nincs megadott TREK-verzió, saját felelősségre fut', + 'admin.plugins.activateAnyway': 'Aktiválás mégis', + 'admin.plugins.untestedChip': 'TREK {host} verzióhoz még nincs megerősítve', + 'admin.plugins.filters': 'Szűrés', + 'admin.plugins.security.short': 'Így védjük a pluginokat', 'admin.plugins.accessTitle': 'Mihez fér hozzá', 'admin.plugins.connectsTitle': 'Kapcsolódik ehhez', 'admin.plugins.detailsTitle': 'Részletek', diff --git a/shared/src/i18n/id/admin.ts b/shared/src/i18n/id/admin.ts index da849ec77..afb2b870a 100644 --- a/shared/src/i18n/id/admin.ts +++ b/shared/src/i18n/id/admin.ts @@ -646,7 +646,17 @@ const admin: TranslationStrings = { '“{name}” tidak menyatakan versi TREK mana yang didukungnya; server ini menjalankan {host}. TREK meloloskannya hanya karena TREK_PLUGINS_IGNORE_TREK_RANGE disetel. Tidak ada tanda bahwa penulisnya mengujinya di TREK ini, jadi tidak ada jaminan plugin ini berfungsi — dan dalam kasus yang jarang, plugin yang tidak cocok dapat merusak data TREK. Lanjutkan hanya jika Anda menerima risiko itu.', 'admin.plugins.dep.trekBypassed': 'Di luar rentang TREK-nya ({range}) — pemeriksaan versi nonaktif', 'admin.plugins.dep.trekBypassedUnknown': 'Tidak menyatakan rentang TREK — pemeriksaan versi nonaktif', - 'admin.plugins.incompatible': 'Tidak kompatibel', + 'admin.plugins.rangeBypass.titleAdmin': 'Belum diuji di TREK ini', + 'admin.plugins.rangeBypass.bodyAdmin': + 'Menurut pembuatnya, “{name}” mendukung TREK {range}, sedangkan server ini menjalankan {host}. Pembuatnya belum mengonfirmasinya untuk TREK ini, jadi mungkin tidak berfungsi, dan dalam kasus langka plugin yang tidak cocok dapat merusak data TREK. Kamu tetap bisa memakainya: pilihanmu hanya berlaku untuk plugin ini di versi ini, dan TREK akan bertanya lagi setelah pembaruan.', + 'admin.plugins.rangeBypass.bodyAdminUnknown': + '“{name}” tidak menyebutkan versi TREK mana yang didukung, sedangkan server ini menjalankan {host}. Tidak ada tanda bahwa plugin ini sudah diuji di TREK ini, jadi mungkin tidak berfungsi, dan dalam kasus langka plugin yang tidak cocok dapat merusak data TREK. Kamu tetap bisa memakainya: pilihanmu hanya berlaku untuk plugin ini di versi ini, dan TREK akan bertanya lagi setelah pembaruan.', + 'admin.plugins.dep.trekAccepted': 'Dibuat untuk TREK {range}, berjalan atas risikomu', + 'admin.plugins.dep.trekAcceptedUnknown': 'Tanpa versi TREK, berjalan atas risikomu', + 'admin.plugins.activateAnyway': 'Tetap aktifkan', + 'admin.plugins.untestedChip': 'Belum dikonfirmasi untuk TREK {host}', + 'admin.plugins.filters': 'Filter', + 'admin.plugins.security.short': 'Cara plugin diamankan', 'admin.plugins.accessTitle': 'Yang bisa diaksesnya', 'admin.plugins.connectsTitle': 'Terhubung ke', 'admin.plugins.detailsTitle': 'Detail', diff --git a/shared/src/i18n/it/admin.ts b/shared/src/i18n/it/admin.ts index 6360cf808..81ff1952b 100644 --- a/shared/src/i18n/it/admin.ts +++ b/shared/src/i18n/it/admin.ts @@ -600,7 +600,17 @@ const admin: TranslationStrings = { '“{name}” non dichiara quali versioni di TREK supporta; questo server esegue {host}. TREK lo lascia passare solo perché TREK_PLUGINS_IGNORE_TREK_RANGE è impostato. Nulla indica che l’autore lo abbia testato su questo TREK, quindi non c’è garanzia che funzioni — e in rari casi un plugin incompatibile può corrompere i dati di TREK. Continua solo se accetti questo rischio.', 'admin.plugins.dep.trekBypassed': 'Fuori dal suo intervallo TREK ({range}) — controlli di versione disattivati', 'admin.plugins.dep.trekBypassedUnknown': 'Non dichiara alcun intervallo TREK — controlli di versione disattivati', - 'admin.plugins.incompatible': 'Non compatibile', + 'admin.plugins.rangeBypass.titleAdmin': 'Non testato su questo TREK', + 'admin.plugins.rangeBypass.bodyAdmin': + 'Secondo il suo autore, «{name}» supporta TREK {range}, e questo server esegue {host}. L’autore non lo ha ancora confermato per questo TREK, quindi potrebbe non funzionare e, in rari casi, un plugin non adatto può danneggiare i dati di TREK. Puoi comunque usarlo: la tua scelta vale solo per questo plugin in questa versione, e TREK lo richiederà dopo un aggiornamento.', + 'admin.plugins.rangeBypass.bodyAdminUnknown': + '«{name}» non indica quali versioni di TREK supporta, e questo server esegue {host}. Niente indica che sia stato testato su questo TREK, quindi potrebbe non funzionare e, in rari casi, un plugin non adatto può danneggiare i dati di TREK. Puoi comunque usarlo: la tua scelta vale solo per questo plugin in questa versione, e TREK lo richiederà dopo un aggiornamento.', + 'admin.plugins.dep.trekAccepted': 'Pensato per TREK {range}, in uso a tuo rischio', + 'admin.plugins.dep.trekAcceptedUnknown': 'Nessuna versione di TREK indicata, in uso a tuo rischio', + 'admin.plugins.activateAnyway': 'Attiva comunque', + 'admin.plugins.untestedChip': 'Non ancora confermato per TREK {host}', + 'admin.plugins.filters': 'Filtra', + 'admin.plugins.security.short': 'Come sono protetti i plugin', 'admin.plugins.accessTitle': 'A cosa può accedere', 'admin.plugins.connectsTitle': 'Si connette a', 'admin.plugins.detailsTitle': 'Dettagli', diff --git a/shared/src/i18n/ja/admin.ts b/shared/src/i18n/ja/admin.ts index 81341024a..7bc75bfa3 100644 --- a/shared/src/i18n/ja/admin.ts +++ b/shared/src/i18n/ja/admin.ts @@ -610,7 +610,17 @@ const admin: TranslationStrings = { '「{name}」はサポートする TREK バージョンを宣言していません。このサーバーは {host} で動作しています。TREK_PLUGINS_IGNORE_TREK_RANGE が設定されているため TREK はこれを許可します。作者がこの TREK で検証した形跡はなく、動作する保証はありません。まれに不適合なプラグインが TREK のデータを破損することがあります。このリスクを受け入れる場合のみ続行してください。', 'admin.plugins.dep.trekBypassed': 'TREK 範囲外({range})— バージョンチェック無効', 'admin.plugins.dep.trekBypassedUnknown': 'TREK 範囲の宣言なし — バージョンチェック無効', - 'admin.plugins.incompatible': '非対応', + 'admin.plugins.rangeBypass.titleAdmin': 'この TREK では未検証', + 'admin.plugins.rangeBypass.bodyAdmin': + '作者によると「{name}」は TREK {range} に対応しており、このサーバーは {host} で動いています。作者はまだこの TREK での動作を確認していないため、動かない可能性があり、まれに合わないプラグインが TREK のデータを壊すこともあります。それでも使うことはできます。この選択はこのバージョンのこのプラグインにだけ適用され、アップデート後に TREK が改めて確認します。', + 'admin.plugins.rangeBypass.bodyAdminUnknown': + '「{name}」は対応する TREK のバージョンを示しておらず、このサーバーは {host} で動いています。この TREK で検証された形跡はないため、動かない可能性があり、まれに合わないプラグインが TREK のデータを壊すこともあります。それでも使うことはできます。この選択はこのバージョンのこのプラグインにだけ適用され、アップデート後に TREK が改めて確認します。', + 'admin.plugins.dep.trekAccepted': 'TREK {range} 向け、自己責任で稼働中', + 'admin.plugins.dep.trekAcceptedUnknown': 'TREK の対応範囲なし、自己責任で稼働中', + 'admin.plugins.activateAnyway': 'それでも有効にする', + 'admin.plugins.untestedChip': 'TREK {host} での動作は未確認', + 'admin.plugins.filters': 'フィルター', + 'admin.plugins.security.short': 'プラグインの保護のしくみ', 'admin.plugins.accessTitle': 'アクセスできるもの', 'admin.plugins.connectsTitle': '接続先', 'admin.plugins.detailsTitle': '詳細', diff --git a/shared/src/i18n/ko/admin.ts b/shared/src/i18n/ko/admin.ts index cafe6322f..26dbc8cc5 100644 --- a/shared/src/i18n/ko/admin.ts +++ b/shared/src/i18n/ko/admin.ts @@ -609,7 +609,17 @@ const admin: TranslationStrings = { '“{name}”은(는) 지원하는 TREK 버전을 선언하지 않았습니다. 이 서버는 {host}을(를) 실행 중입니다. TREK_PLUGINS_IGNORE_TREK_RANGE가 설정되어 있기 때문에만 TREK가 이를 허용합니다. 작성자가 이 TREK에서 테스트했다는 근거가 없으므로 정상 동작을 보장할 수 없으며, 드물게 호환되지 않는 플러그인이 TREK 데이터를 손상시킬 수 있습니다. 이 위험을 감수하는 경우에만 계속하세요.', 'admin.plugins.dep.trekBypassed': 'TREK 범위 밖 ({range}) — 버전 검사 꺼짐', 'admin.plugins.dep.trekBypassedUnknown': 'TREK 범위 선언 없음 — 버전 검사 꺼짐', - 'admin.plugins.incompatible': '호환되지 않음', + 'admin.plugins.rangeBypass.titleAdmin': '이 TREK에서 검증되지 않음', + 'admin.plugins.rangeBypass.bodyAdmin': + '제작자에 따르면 “{name}”은(는) TREK {range}을(를) 지원하며, 이 서버는 {host}을(를) 실행 중입니다. 제작자가 아직 이 TREK에서 확인하지 않았으므로 작동하지 않을 수 있고, 드물게 맞지 않는 플러그인이 TREK 데이터를 손상시킬 수 있습니다. 그래도 사용할 수 있습니다. 이 선택은 이 버전의 이 플러그인에만 적용되며, 업데이트 후 TREK이 다시 묻습니다.', + 'admin.plugins.rangeBypass.bodyAdminUnknown': + '“{name}”은(는) 지원하는 TREK 버전을 밝히지 않으며, 이 서버는 {host}을(를) 실행 중입니다. 이 TREK에서 테스트되었다는 근거가 없으므로 작동하지 않을 수 있고, 드물게 맞지 않는 플러그인이 TREK 데이터를 손상시킬 수 있습니다. 그래도 사용할 수 있습니다. 이 선택은 이 버전의 이 플러그인에만 적용되며, 업데이트 후 TREK이 다시 묻습니다.', + 'admin.plugins.dep.trekAccepted': 'TREK {range}용, 직접 책임지고 실행 중', + 'admin.plugins.dep.trekAcceptedUnknown': 'TREK 버전 정보 없음, 직접 책임지고 실행 중', + 'admin.plugins.activateAnyway': '그래도 활성화', + 'admin.plugins.untestedChip': 'TREK {host}에서 아직 확인되지 않음', + 'admin.plugins.filters': '필터', + 'admin.plugins.security.short': '플러그인 보호 방식', 'admin.plugins.accessTitle': '접근할 수 있는 항목', 'admin.plugins.connectsTitle': '연결 대상', 'admin.plugins.detailsTitle': '상세 정보', diff --git a/shared/src/i18n/nl/admin.ts b/shared/src/i18n/nl/admin.ts index 662679536..7f556ef75 100644 --- a/shared/src/i18n/nl/admin.ts +++ b/shared/src/i18n/nl/admin.ts @@ -599,7 +599,17 @@ const admin: TranslationStrings = { '“{name}” geeft niet op welke TREK-versies het ondersteunt; deze server draait {host}. TREK laat het alleen door omdat TREK_PLUGINS_IGNORE_TREK_RANGE is ingesteld. Niets wijst erop dat de auteur het op deze TREK heeft getest, dus er is geen garantie dat het werkt — en in zeldzame gevallen kan een niet-passende plugin TREK-gegevens beschadigen. Ga alleen verder als je dat risico accepteert.', 'admin.plugins.dep.trekBypassed': 'Buiten zijn TREK-bereik ({range}) — versiecontroles uit', 'admin.plugins.dep.trekBypassedUnknown': 'Geeft geen TREK-bereik op — versiecontroles uit', - 'admin.plugins.incompatible': 'Niet compatibel', + 'admin.plugins.rangeBypass.titleAdmin': 'Niet getest op deze TREK', + 'admin.plugins.rangeBypass.bodyAdmin': + 'Volgens de maker ondersteunt “{name}” TREK {range}, en deze server draait {host}. De maker heeft het nog niet voor deze TREK bevestigd, dus het werkt mogelijk niet, en in zeldzame gevallen kan een plugin die niet past TREK-gegevens beschadigen. Je kunt het toch gebruiken: je keuze geldt alleen voor deze plugin in deze versie, en na een update vraagt TREK het opnieuw.', + 'admin.plugins.rangeBypass.bodyAdminUnknown': + '“{name}” geeft niet aan welke TREK-versies het ondersteunt, en deze server draait {host}. Niets wijst erop dat het op deze TREK is getest, dus het werkt mogelijk niet, en in zeldzame gevallen kan een plugin die niet past TREK-gegevens beschadigen. Je kunt het toch gebruiken: je keuze geldt alleen voor deze plugin in deze versie, en na een update vraagt TREK het opnieuw.', + 'admin.plugins.dep.trekAccepted': 'Gemaakt voor TREK {range}, draait op eigen risico', + 'admin.plugins.dep.trekAcceptedUnknown': 'Geen TREK-versie opgegeven, draait op eigen risico', + 'admin.plugins.activateAnyway': 'Toch activeren', + 'admin.plugins.untestedChip': 'Nog niet bevestigd voor TREK {host}', + 'admin.plugins.filters': 'Filter', + 'admin.plugins.security.short': 'Zo zijn plugins beveiligd', 'admin.plugins.accessTitle': 'Waartoe hij toegang heeft', 'admin.plugins.connectsTitle': 'Maakt verbinding met', 'admin.plugins.detailsTitle': 'Details', // same-as-en diff --git a/shared/src/i18n/pl/admin.ts b/shared/src/i18n/pl/admin.ts index e2176fa5e..f9901dc1a 100644 --- a/shared/src/i18n/pl/admin.ts +++ b/shared/src/i18n/pl/admin.ts @@ -615,7 +615,17 @@ const admin: TranslationStrings = { '„{name}” nie deklaruje, które wersje TREK obsługuje; ten serwer działa na {host}. TREK przepuszcza ją tylko dlatego, że ustawiono TREK_PLUGINS_IGNORE_TREK_RANGE. Nic nie wskazuje, że autor testował ją na tym TREK, więc nie ma gwarancji, że zadziała — a w rzadkich przypadkach niedopasowana wtyczka może uszkodzić dane TREK. Kontynuuj tylko, jeśli akceptujesz to ryzyko.', 'admin.plugins.dep.trekBypassed': 'Poza swoim zakresem TREK ({range}) — kontrola wersji wyłączona', 'admin.plugins.dep.trekBypassedUnknown': 'Nie deklaruje zakresu TREK — kontrola wersji wyłączona', - 'admin.plugins.incompatible': 'Niezgodny', + 'admin.plugins.rangeBypass.titleAdmin': 'Nietestowany na tym TREK', + 'admin.plugins.rangeBypass.bodyAdmin': + 'Według autora „{name}” obsługuje TREK {range}, a ten serwer działa na {host}. Autor nie potwierdził go jeszcze dla tego TREK, więc może nie działać, a w rzadkich przypadkach niepasujący plugin może uszkodzić dane TREK. Możesz go mimo to używać: twoja decyzja dotyczy tylko tego pluginu w tej wersji, a po aktualizacji TREK zapyta ponownie.', + 'admin.plugins.rangeBypass.bodyAdminUnknown': + '„{name}” nie podaje, które wersje TREK obsługuje, a ten serwer działa na {host}. Nic nie wskazuje, że był testowany na tym TREK, więc może nie działać, a w rzadkich przypadkach niepasujący plugin może uszkodzić dane TREK. Możesz go mimo to używać: twoja decyzja dotyczy tylko tego pluginu w tej wersji, a po aktualizacji TREK zapyta ponownie.', + 'admin.plugins.dep.trekAccepted': 'Stworzony dla TREK {range}, działa na twoje ryzyko', + 'admin.plugins.dep.trekAcceptedUnknown': 'Brak podanej wersji TREK, działa na twoje ryzyko', + 'admin.plugins.activateAnyway': 'Aktywuj mimo to', + 'admin.plugins.untestedChip': 'Jeszcze niepotwierdzony dla TREK {host}', + 'admin.plugins.filters': 'Filtruj', + 'admin.plugins.security.short': 'Jak zabezpieczone są pluginy', 'admin.plugins.accessTitle': 'Do czego ma dostęp', 'admin.plugins.connectsTitle': 'Łączy się z', 'admin.plugins.detailsTitle': 'Szczegóły', diff --git a/shared/src/i18n/ru/admin.ts b/shared/src/i18n/ru/admin.ts index 16c41cbcb..aadbb7aed 100644 --- a/shared/src/i18n/ru/admin.ts +++ b/shared/src/i18n/ru/admin.ts @@ -620,7 +620,17 @@ const admin: TranslationStrings = { '«{name}» не указывает, какие версии TREK он поддерживает; этот сервер работает на {host}. TREK пропускает его только потому, что задано TREK_PLUGINS_IGNORE_TREK_RANGE. Ничто не говорит о том, что автор тестировал его на этом TREK, так что работоспособность не гарантируется — а в редких случаях несовместимый плагин может повредить данные TREK. Продолжайте, только если принимаете этот риск.', 'admin.plugins.dep.trekBypassed': 'Вне своего диапазона TREK ({range}) — проверки версии отключены', 'admin.plugins.dep.trekBypassedUnknown': 'Диапазон TREK не указан — проверки версии отключены', - 'admin.plugins.incompatible': 'Несовместимо', + 'admin.plugins.rangeBypass.titleAdmin': 'Не проверен на этом TREK', + 'admin.plugins.rangeBypass.bodyAdmin': + 'По словам автора, «{name}» поддерживает TREK {range}, а на этом сервере работает {host}. Автор ещё не подтвердил его для этого TREK, поэтому он может не работать, а в редких случаях неподходящий плагин может повредить данные TREK. Вы всё равно можете его использовать: ваше решение действует только для этого плагина в этой версии, после обновления TREK спросит снова.', + 'admin.plugins.rangeBypass.bodyAdminUnknown': + '«{name}» не указывает, какие версии TREK поддерживает, а на этом сервере работает {host}. Ничто не говорит о том, что его проверяли на этом TREK, поэтому он может не работать, а в редких случаях неподходящий плагин может повредить данные TREK. Вы всё равно можете его использовать: ваше решение действует только для этого плагина в этой версии, после обновления TREK спросит снова.', + 'admin.plugins.dep.trekAccepted': 'Создан для TREK {range}, работает на ваш риск', + 'admin.plugins.dep.trekAcceptedUnknown': 'Версия TREK не указана, работает на ваш риск', + 'admin.plugins.activateAnyway': 'Всё равно включить', + 'admin.plugins.untestedChip': 'Ещё не подтверждён для TREK {host}', + 'admin.plugins.filters': 'Фильтр', + 'admin.plugins.security.short': 'Как защищены плагины', 'admin.plugins.accessTitle': 'К чему есть доступ', 'admin.plugins.connectsTitle': 'Подключается к', 'admin.plugins.detailsTitle': 'Подробности', diff --git a/shared/src/i18n/sk/admin.ts b/shared/src/i18n/sk/admin.ts index 33f9447de..e36e76923 100644 --- a/shared/src/i18n/sk/admin.ts +++ b/shared/src/i18n/sk/admin.ts @@ -669,7 +669,17 @@ const admin: TranslationStrings = { '„{name}“ neuvádza, ktoré verzie TREKu podporuje; tento server beží na {host}. TREK ho prepúšťa len preto, že je nastavené TREK_PLUGINS_IGNORE_TREK_RANGE. Nič nenasvedčuje tomu, že ho autor na tomto TREKu testoval, takže nie je zaručené, že bude fungovať — a v zriedkavých prípadoch môže nekompatibilný plugin poškodiť dáta TREKu. Pokračujte len vtedy, ak toto riziko prijímate.', 'admin.plugins.dep.trekBypassed': 'Mimo svojho rozsahu TREKu ({range}) — kontroly verzií vypnuté', 'admin.plugins.dep.trekBypassedUnknown': 'Neuvádza žiadny rozsah TREKu — kontroly verzií vypnuté', - 'admin.plugins.incompatible': 'Nekompatibilný', + 'admin.plugins.rangeBypass.titleAdmin': 'Na tomto TREK netestované', + 'admin.plugins.rangeBypass.bodyAdmin': + 'Podľa autora „{name}“ podporuje TREK {range} a tento server beží na {host}. Autor ho pre tento TREK zatiaľ nepotvrdil, takže nemusí fungovať a v zriedkavých prípadoch môže nevhodný plugin poškodiť dáta TREK. Napriek tomu ho môžete používať: vaše rozhodnutie platí len pre tento plugin v tejto verzii a po aktualizácii sa TREK opýta znova.', + 'admin.plugins.rangeBypass.bodyAdminUnknown': + '„{name}“ neuvádza, ktoré verzie TREK podporuje, a tento server beží na {host}. Nič nenasvedčuje tomu, že bol na tomto TREK testovaný, takže nemusí fungovať a v zriedkavých prípadoch môže nevhodný plugin poškodiť dáta TREK. Napriek tomu ho môžete používať: vaše rozhodnutie platí len pre tento plugin v tejto verzii a po aktualizácii sa TREK opýta znova.', + 'admin.plugins.dep.trekAccepted': 'Vytvorené pre TREK {range}, beží na vlastné riziko', + 'admin.plugins.dep.trekAcceptedUnknown': 'Bez uvedenej verzie TREK, beží na vlastné riziko', + 'admin.plugins.activateAnyway': 'Napriek tomu aktivovať', + 'admin.plugins.untestedChip': 'Pre TREK {host} zatiaľ nepotvrdené', + 'admin.plugins.filters': 'Filter', + 'admin.plugins.security.short': 'Ako sú pluginy zabezpečené', 'admin.plugins.accessTitle': 'K čomu má prístup', 'admin.plugins.connectsTitle': 'Pripája sa k', 'admin.plugins.detailsTitle': 'Podrobnosti', diff --git a/shared/src/i18n/sv/admin.ts b/shared/src/i18n/sv/admin.ts index 7dbf319f7..cb0343a56 100644 --- a/shared/src/i18n/sv/admin.ts +++ b/shared/src/i18n/sv/admin.ts @@ -668,7 +668,17 @@ const admin: TranslationStrings = { '”{name}” anger inte vilka TREK-versioner det stöder; den här servern kör {host}. TREK släpper igenom det bara för att TREK_PLUGINS_IGNORE_TREK_RANGE är satt. Inget tyder på att upphovspersonen har testat det på den här TREK-versionen, så det finns ingen garanti att det fungerar — och i sällsynta fall kan ett plugin som inte passar skada TREK-data. Fortsätt bara om du accepterar den risken.', 'admin.plugins.dep.trekBypassed': 'Utanför sitt TREK-intervall ({range}) — versionskontroller av', 'admin.plugins.dep.trekBypassedUnknown': 'Anger inget TREK-intervall — versionskontroller av', - 'admin.plugins.incompatible': 'Inkompatibel', + 'admin.plugins.rangeBypass.titleAdmin': 'Otestad på denna TREK', + 'admin.plugins.rangeBypass.bodyAdmin': + 'Enligt upphovspersonen stöder ”{name}” TREK {range}, och den här servern kör {host}. Upphovspersonen har ännu inte bekräftat den för denna TREK, så den kanske inte fungerar, och i sällsynta fall kan ett plugin som inte passar skada TREK-data. Du kan ändå använda den: ditt val gäller bara detta plugin i den här versionen, och efter en uppdatering frågar TREK igen.', + 'admin.plugins.rangeBypass.bodyAdminUnknown': + '”{name}” anger inte vilka TREK-versioner den stöder, och den här servern kör {host}. Inget tyder på att den har testats på denna TREK, så den kanske inte fungerar, och i sällsynta fall kan ett plugin som inte passar skada TREK-data. Du kan ändå använda den: ditt val gäller bara detta plugin i den här versionen, och efter en uppdatering frågar TREK igen.', + 'admin.plugins.dep.trekAccepted': 'Gjord för TREK {range}, körs på egen risk', + 'admin.plugins.dep.trekAcceptedUnknown': 'Ingen TREK-version angiven, körs på egen risk', + 'admin.plugins.activateAnyway': 'Aktivera ändå', + 'admin.plugins.untestedChip': 'Ännu inte bekräftad för TREK {host}', + 'admin.plugins.filters': 'Filter', + 'admin.plugins.security.short': 'Så skyddas plugins', 'admin.plugins.accessTitle': 'Vad det har åtkomst till', 'admin.plugins.connectsTitle': 'Ansluter till', 'admin.plugins.detailsTitle': 'Detaljer', diff --git a/shared/src/i18n/th/admin.ts b/shared/src/i18n/th/admin.ts index 882ac3280..95f5a53bf 100644 --- a/shared/src/i18n/th/admin.ts +++ b/shared/src/i18n/th/admin.ts @@ -577,7 +577,17 @@ const admin: TranslationStrings = { '“{name}” ไม่ได้ประกาศว่า TREK เวอร์ชันใดรองรับ เซิร์ฟเวอร์นี้รัน {host} TREK ปล่อยให้ผ่านไปได้เพียงเพราะตั้งค่า TREK_PLUGINS_IGNORE_TREK_RANGE แล้ว ไม่มีสิ่งใดบอกว่าผู้เขียนได้ทำการทดสอบบน TREK นี้ ดังนั้นจึงไม่มีการรับประกันว่าจะใช้งานได้ และในบางกรณีที่เกิดขึ้นไม่บ่อยนัก ปลั๊กอินที่ไม่ตรงกันอาจทำให้ข้อมูล TREK เสียหายได้ ดำเนินการต่อเฉพาะเมื่อคุณยอมรับความเสี่ยงนั้น', 'admin.plugins.dep.trekBypassed': 'อยู่นอกช่วง TREK ({range}) — เวอร์ชันถูกทำเครื่องหมายไว้', 'admin.plugins.dep.trekBypassedUnknown': 'ประกาศไม่มีช่วง TREK — เวอร์ชันถูกทำเครื่องหมายไว้', - 'admin.plugins.incompatible': 'เข้ากันไม่ได้', + 'admin.plugins.rangeBypass.titleAdmin': 'ยังไม่ได้ทดสอบกับ TREK นี้', + 'admin.plugins.rangeBypass.bodyAdmin': + 'ตามที่ผู้สร้างระบุ “{name}” รองรับ TREK {range} ส่วนเซิร์ฟเวอร์นี้ใช้ {host} ผู้สร้างยังไม่ได้ยืนยันว่าใช้กับ TREK นี้ได้ จึงอาจทำงานไม่ได้ และในบางกรณีที่พบได้ยาก ปลั๊กอินที่ไม่เข้ากันอาจทำให้ข้อมูล TREK เสียหาย คุณยังใช้งานได้: การเลือกของคุณมีผลกับปลั๊กอินนี้ในเวอร์ชันนี้เท่านั้น และ TREK จะถามอีกครั้งหลังการอัปเดต', + 'admin.plugins.rangeBypass.bodyAdminUnknown': + '“{name}” ไม่ได้ระบุว่ารองรับ TREK เวอร์ชันใด ส่วนเซิร์ฟเวอร์นี้ใช้ {host} ไม่มีสิ่งใดบอกว่าได้ทดสอบกับ TREK นี้แล้ว จึงอาจทำงานไม่ได้ และในบางกรณีที่พบได้ยาก ปลั๊กอินที่ไม่เข้ากันอาจทำให้ข้อมูล TREK เสียหาย คุณยังใช้งานได้: การเลือกของคุณมีผลกับปลั๊กอินนี้ในเวอร์ชันนี้เท่านั้น และ TREK จะถามอีกครั้งหลังการอัปเดต', + 'admin.plugins.dep.trekAccepted': 'สร้างสำหรับ TREK {range} ใช้งานโดยคุณรับความเสี่ยงเอง', + 'admin.plugins.dep.trekAcceptedUnknown': 'ไม่ได้ระบุเวอร์ชัน TREK ใช้งานโดยคุณรับความเสี่ยงเอง', + 'admin.plugins.activateAnyway': 'เปิดใช้งานต่อไป', + 'admin.plugins.untestedChip': 'ยังไม่ยืนยันว่าใช้กับ TREK {host} ได้', + 'admin.plugins.filters': 'ตัวกรอง', + 'admin.plugins.security.short': 'ปลั๊กอินได้รับการป้องกันอย่างไร', 'admin.plugins.accessTitle': 'สิ่งที่สามารถเข้าถึงได้', 'admin.plugins.connectsTitle': 'เชื่อมต่อกับ', 'admin.plugins.detailsTitle': 'รายละเอียด', diff --git a/shared/src/i18n/tr/admin.ts b/shared/src/i18n/tr/admin.ts index fb50bccf1..1c0e562d9 100644 --- a/shared/src/i18n/tr/admin.ts +++ b/shared/src/i18n/tr/admin.ts @@ -653,7 +653,17 @@ const admin: TranslationStrings = { '“{name}” hangi TREK sürümlerini desteklediğini bildirmiyor; bu sunucu {host} çalıştırıyor. TREK yalnızca TREK_PLUGINS_IGNORE_TREK_RANGE ayarlı olduğu için izin veriyor. Yazarının bu TREK üzerinde test ettiğine dair bir işaret yok, dolayısıyla çalışacağının garantisi yok — ve nadir durumlarda uyumsuz bir eklenti TREK verilerini bozabilir. Yalnızca bu riski kabul ediyorsanız devam edin.', 'admin.plugins.dep.trekBypassed': 'TREK aralığının dışında ({range}) — sürüm denetimleri kapalı', 'admin.plugins.dep.trekBypassedUnknown': 'TREK aralığı bildirmiyor — sürüm denetimleri kapalı', - 'admin.plugins.incompatible': 'Uyumsuz', + 'admin.plugins.rangeBypass.titleAdmin': "Bu TREK'te test edilmedi", + 'admin.plugins.rangeBypass.bodyAdmin': + 'Geliştiricisine göre “{name}” TREK {range} sürümlerini destekliyor, bu sunucu ise {host} çalıştırıyor. Geliştirici onu bu TREK için henüz onaylamadı; çalışmayabilir ve nadiren uyumsuz bir eklenti TREK verilerine zarar verebilir. Yine de kullanabilirsin: seçimin yalnızca bu sürümdeki bu eklenti için geçerli, bir güncellemeden sonra TREK yeniden sorar.', + 'admin.plugins.rangeBypass.bodyAdminUnknown': + "“{name}” hangi TREK sürümlerini desteklediğini belirtmiyor, bu sunucu ise {host} çalıştırıyor. Bu TREK'te test edildiğine dair bir işaret yok; çalışmayabilir ve nadiren uyumsuz bir eklenti TREK verilerine zarar verebilir. Yine de kullanabilirsin: seçimin yalnızca bu sürümdeki bu eklenti için geçerli, bir güncellemeden sonra TREK yeniden sorar.", + 'admin.plugins.dep.trekAccepted': 'TREK {range} için yapıldı, sorumluluk sende', + 'admin.plugins.dep.trekAcceptedUnknown': 'TREK sürümü belirtilmemiş, sorumluluk sende', + 'admin.plugins.activateAnyway': 'Yine de etkinleştir', + 'admin.plugins.untestedChip': 'TREK {host} için henüz onaylanmadı', + 'admin.plugins.filters': 'Filtre', + 'admin.plugins.security.short': 'Eklentiler nasıl korunuyor', 'admin.plugins.accessTitle': 'Neye erişebilir', 'admin.plugins.connectsTitle': 'Şuraya bağlanır', 'admin.plugins.detailsTitle': 'Detaylar', diff --git a/shared/src/i18n/uk/admin.ts b/shared/src/i18n/uk/admin.ts index eaacace7e..90a9232c6 100644 --- a/shared/src/i18n/uk/admin.ts +++ b/shared/src/i18n/uk/admin.ts @@ -617,7 +617,17 @@ const admin: TranslationStrings = { '«{name}» не вказує, які версії TREK він підтримує; цей сервер працює на {host}. TREK пропускає його лише тому, що задано TREK_PLUGINS_IGNORE_TREK_RANGE. Ніщо не свідчить, що автор тестував його на цьому TREK, тож працездатність не гарантується — а в рідкісних випадках несумісний плагін може пошкодити дані TREK. Продовжуйте, лише якщо приймаєте цей ризик.', 'admin.plugins.dep.trekBypassed': 'Поза своїм діапазоном TREK ({range}) — перевірки версії вимкнено', 'admin.plugins.dep.trekBypassedUnknown': 'Діапазон TREK не вказано — перевірки версії вимкнено', - 'admin.plugins.incompatible': 'Несумісно', + 'admin.plugins.rangeBypass.titleAdmin': 'Не перевірено на цьому TREK', + 'admin.plugins.rangeBypass.bodyAdmin': + 'За словами автора, «{name}» підтримує TREK {range}, а на цьому сервері працює {host}. Автор ще не підтвердив його для цього TREK, тож він може не працювати, а в рідкісних випадках невідповідний плагін може пошкодити дані TREK. Ви все одно можете ним користуватися: ваше рішення діє лише для цього плагіна в цій версії, після оновлення TREK запитає знову.', + 'admin.plugins.rangeBypass.bodyAdminUnknown': + '«{name}» не вказує, які версії TREK підтримує, а на цьому сервері працює {host}. Ніщо не вказує на те, що його перевіряли на цьому TREK, тож він може не працювати, а в рідкісних випадках невідповідний плагін може пошкодити дані TREK. Ви все одно можете ним користуватися: ваше рішення діє лише для цього плагіна в цій версії, після оновлення TREK запитає знову.', + 'admin.plugins.dep.trekAccepted': 'Створено для TREK {range}, працює на ваш ризик', + 'admin.plugins.dep.trekAcceptedUnknown': 'Версію TREK не вказано, працює на ваш ризик', + 'admin.plugins.activateAnyway': 'Однаково увімкнути', + 'admin.plugins.untestedChip': 'Ще не підтверджено для TREK {host}', + 'admin.plugins.filters': 'Фільтр', + 'admin.plugins.security.short': 'Як захищені плагіни', 'admin.plugins.accessTitle': 'До чого має доступ', 'admin.plugins.connectsTitle': 'Підключається до', 'admin.plugins.detailsTitle': 'Деталі', diff --git a/shared/src/i18n/vi/admin.ts b/shared/src/i18n/vi/admin.ts index 87384f2a3..1ae81893d 100644 --- a/shared/src/i18n/vi/admin.ts +++ b/shared/src/i18n/vi/admin.ts @@ -660,7 +660,17 @@ const admin: TranslationStrings = { '“{name}” không khai báo hỗ trợ phiên bản TREK nào; máy chủ này đang chạy {host}. TREK chỉ cho phép vì TREK_PLUGINS_IGNORE_TREK_RANGE đã được đặt. Không có dấu hiệu nào cho thấy tác giả đã thử nghiệm trên TREK này, nên không có gì đảm bảo nó hoạt động — và trong một số ít trường hợp, plugin không tương thích có thể làm hỏng dữ liệu TREK. Chỉ tiếp tục nếu bạn chấp nhận rủi ro đó.', 'admin.plugins.dep.trekBypassed': 'Ngoài phạm vi TREK của nó ({range}) — đã tắt kiểm tra phiên bản', 'admin.plugins.dep.trekBypassedUnknown': 'Không khai báo phạm vi TREK — đã tắt kiểm tra phiên bản', - 'admin.plugins.incompatible': 'Không tương thích', + 'admin.plugins.rangeBypass.titleAdmin': 'Chưa thử trên TREK này', + 'admin.plugins.rangeBypass.bodyAdmin': + 'Theo tác giả, “{name}” hỗ trợ TREK {range}, còn máy chủ này chạy {host}. Tác giả chưa xác nhận nó cho TREK này, nên nó có thể không hoạt động, và trong vài trường hợp hiếm, một plugin không phù hợp có thể làm hỏng dữ liệu TREK. Bạn vẫn có thể dùng nó: lựa chọn của bạn chỉ áp dụng cho plugin này ở phiên bản này, và TREK sẽ hỏi lại sau khi cập nhật.', + 'admin.plugins.rangeBypass.bodyAdminUnknown': + '“{name}” không cho biết hỗ trợ những phiên bản TREK nào, còn máy chủ này chạy {host}. Không có dấu hiệu nào cho thấy nó đã được thử trên TREK này, nên nó có thể không hoạt động, và trong vài trường hợp hiếm, một plugin không phù hợp có thể làm hỏng dữ liệu TREK. Bạn vẫn có thể dùng nó: lựa chọn của bạn chỉ áp dụng cho plugin này ở phiên bản này, và TREK sẽ hỏi lại sau khi cập nhật.', + 'admin.plugins.dep.trekAccepted': 'Làm cho TREK {range}, chạy với rủi ro của bạn', + 'admin.plugins.dep.trekAcceptedUnknown': 'Không ghi phiên bản TREK, chạy với rủi ro của bạn', + 'admin.plugins.activateAnyway': 'Vẫn kích hoạt', + 'admin.plugins.untestedChip': 'Chưa được xác nhận cho TREK {host}', + 'admin.plugins.filters': 'Lọc', + 'admin.plugins.security.short': 'Cách plugin được bảo vệ', 'admin.plugins.accessTitle': 'Những gì có thể truy cập', 'admin.plugins.connectsTitle': 'Kết nối tới', 'admin.plugins.detailsTitle': 'Chi tiết', diff --git a/shared/src/i18n/zh-TW/admin.ts b/shared/src/i18n/zh-TW/admin.ts index cfaf1290b..238967f24 100644 --- a/shared/src/i18n/zh-TW/admin.ts +++ b/shared/src/i18n/zh-TW/admin.ts @@ -585,7 +585,17 @@ const admin: TranslationStrings = { '「{name}」未宣告支援哪些 TREK 版本;此伺服器執行的是 {host}。僅因為設定了 TREK_PLUGINS_IGNORE_TREK_RANGE,TREK 才放行。沒有跡象顯示作者在此 TREK 上測試過它,因此無法保證它能正常運作——在極少數情況下,不相容的外掛可能損毀 TREK 資料。只有在接受該風險時才繼續。', 'admin.plugins.dep.trekBypassed': '超出其 TREK 範圍({range})— 版本檢查已關閉', 'admin.plugins.dep.trekBypassedUnknown': '未宣告 TREK 範圍 — 版本檢查已關閉', - 'admin.plugins.incompatible': '不相容', + 'admin.plugins.rangeBypass.titleAdmin': '未在此 TREK 上驗證', + 'admin.plugins.rangeBypass.bodyAdmin': + '根據作者說明,「{name}」支援 TREK {range},而此伺服器執行的是 {host}。作者尚未確認它適用於此 TREK,因此它可能無法正常運作,極少數情況下,不相容的外掛可能損壞 TREK 資料。你仍然可以使用它:你的選擇只適用於此版本的此外掛,更新後 TREK 會再次詢問。', + 'admin.plugins.rangeBypass.bodyAdminUnknown': + '「{name}」未說明支援哪些 TREK 版本,而此伺服器執行的是 {host}。沒有跡象顯示它在此 TREK 上測試過,因此它可能無法正常運作,極少數情況下,不相容的外掛可能損壞 TREK 資料。你仍然可以使用它:你的選擇只適用於此版本的此外掛,更新後 TREK 會再次詢問。', + 'admin.plugins.dep.trekAccepted': '為 TREK {range} 製作,風險自負執行中', + 'admin.plugins.dep.trekAcceptedUnknown': '未聲明 TREK 版本,風險自負執行中', + 'admin.plugins.activateAnyway': '仍然啟用', + 'admin.plugins.untestedChip': '尚未確認支援 TREK {host}', + 'admin.plugins.filters': '篩選', + 'admin.plugins.security.short': '外掛如何受到保護', 'admin.plugins.accessTitle': '可存取的內容', 'admin.plugins.connectsTitle': '連線至', 'admin.plugins.detailsTitle': '詳細資訊', diff --git a/shared/src/i18n/zh/admin.ts b/shared/src/i18n/zh/admin.ts index 4193e46e8..dbdf60c7f 100644 --- a/shared/src/i18n/zh/admin.ts +++ b/shared/src/i18n/zh/admin.ts @@ -536,7 +536,17 @@ const admin: TranslationStrings = { '“{name}”未声明支持哪些 TREK 版本;此服务器运行的是 {host}。仅因为设置了 TREK_PLUGINS_IGNORE_TREK_RANGE,TREK 才放行。没有迹象表明作者在此 TREK 上测试过它,因此无法保证它能正常工作——在极少数情况下,不匹配的插件可能损坏 TREK 数据。只有在接受该风险时才继续。', 'admin.plugins.dep.trekBypassed': '超出其 TREK 范围({range})— 版本检查已关闭', 'admin.plugins.dep.trekBypassedUnknown': '未声明 TREK 范围 — 版本检查已关闭', - 'admin.plugins.incompatible': '不兼容', + 'admin.plugins.rangeBypass.titleAdmin': '未在此 TREK 上验证', + 'admin.plugins.rangeBypass.bodyAdmin': + '据作者说明,“{name}” 支持 TREK {range},而此服务器运行的是 {host}。作者尚未确认它适用于此 TREK,因此它可能无法正常工作,极少数情况下,不匹配的插件可能损坏 TREK 数据。你仍然可以使用它:你的选择只适用于此版本的此插件,更新后 TREK 会再次询问。', + 'admin.plugins.rangeBypass.bodyAdminUnknown': + '“{name}” 未说明支持哪些 TREK 版本,而此服务器运行的是 {host}。没有迹象表明它在此 TREK 上测试过,因此它可能无法正常工作,极少数情况下,不匹配的插件可能损坏 TREK 数据。你仍然可以使用它:你的选择只适用于此版本的此插件,更新后 TREK 会再次询问。', + 'admin.plugins.dep.trekAccepted': '为 TREK {range} 制作,风险自负运行中', + 'admin.plugins.dep.trekAcceptedUnknown': '未声明 TREK 版本,风险自负运行中', + 'admin.plugins.activateAnyway': '仍然启用', + 'admin.plugins.untestedChip': '尚未确认支持 TREK {host}', + 'admin.plugins.filters': '筛选', + 'admin.plugins.security.short': '插件如何受到保护', 'admin.plugins.accessTitle': '可访问的内容', 'admin.plugins.connectsTitle': '连接到', 'admin.plugins.detailsTitle': '详情', diff --git a/shared/src/plugins/plugins.schema.ts b/shared/src/plugins/plugins.schema.ts index a5bcd9abe..954f45f4d 100644 --- a/shared/src/plugins/plugins.schema.ts +++ b/shared/src/plugins/plugins.schema.ts @@ -98,6 +98,8 @@ export const pluginInstallRequestSchema = z.looseObject({ version: z.string().optional(), constraint: z.string().optional(), withDependencies: z.boolean().optional(), + /** The admin's "install anyway" for this plugin when no version declares this TREK. */ + acceptTrekRange: z.boolean().optional(), }); export type PluginInstallRequest = z.infer; @@ -106,7 +108,11 @@ export const pluginLinkRequestSchema = z.looseObject({ path: z.string().optional export type PluginLinkRequest = z.infer; /** POST /api/admin/plugins/:id/activate */ -export const pluginActivateRequestSchema = z.looseObject({ consent: z.boolean().optional() }); +export const pluginActivateRequestSchema = z.looseObject({ + consent: z.boolean().optional(), + /** The admin's "run it anyway" for this plugin outside the TREK versions it declares. */ + acceptTrekRange: z.boolean().optional(), +}); export type PluginActivateRequest = z.infer; /** POST /api/admin/plugins/:id/uninstall */