From 40fa8c6fe6a77a5ce6e3bb1477b7729c099e5ac1 Mon Sep 17 00:00:00 2001 From: Maurice Date: Fri, 9 Oct 2026 11:03:01 +0200 Subject: [PATCH] refactor(plugins): share the plugin admin logic between desktop and phone Both panels carried the same types, helpers and a four hundred line block of state and handlers. pluginModel now holds the types and pure helpers, usePluginsAdmin the state, loading and every action, and usePluginDetail the registry detail fetch. The phone keeps starting all updates at once from Update all and reads the detail manifest under its stricter shape; both panels keep their own markup, dialogs and sheets. --- .../components/Admin/AdminPluginsPanel.tsx | 811 +---------------- .../Admin/plugins/pluginModel.test.ts | 212 +++++ .../components/Admin/plugins/pluginModel.ts | 458 ++++++++++ .../Admin/plugins/usePluginDetail.test.ts | 38 + .../Admin/plugins/usePluginDetail.ts | 31 + .../Admin/plugins/usePluginsAdmin.test.ts | 272 ++++++ .../Admin/plugins/usePluginsAdmin.ts | 725 ++++++++++++++++ .../screens/admin/MAdminPluginsPanel.tsx | 818 +----------------- 8 files changed, 1820 insertions(+), 1545 deletions(-) create mode 100644 client/src/components/Admin/plugins/pluginModel.test.ts create mode 100644 client/src/components/Admin/plugins/pluginModel.ts create mode 100644 client/src/components/Admin/plugins/usePluginDetail.test.ts create mode 100644 client/src/components/Admin/plugins/usePluginDetail.ts create mode 100644 client/src/components/Admin/plugins/usePluginsAdmin.test.ts create mode 100644 client/src/components/Admin/plugins/usePluginsAdmin.ts diff --git a/client/src/components/Admin/AdminPluginsPanel.tsx b/client/src/components/Admin/AdminPluginsPanel.tsx index 24611b0a8..d18271294 100644 --- a/client/src/components/Admin/AdminPluginsPanel.tsx +++ b/client/src/components/Admin/AdminPluginsPanel.tsx @@ -1,5 +1,4 @@ -import { useEffect, useMemo, useRef, useState, type CSSProperties, type DragEvent, type ReactNode } from 'react' -import { PLUGIN_PERMISSIONS } from '@trek/shared' +import { useEffect, useRef, useState, type CSSProperties, type ReactNode } from 'react' import { createPortal } from 'react-dom' import { Blocks, AlertTriangle, RefreshCw, Trash2, Download, Bug, ShieldCheck, UploadCloud, @@ -8,14 +7,11 @@ import { Globe, Info, History, PauseCircle, Puzzle, Bot, Loader2, } from 'lucide-react' import PluginIcon from '../shared/PluginIcon' -import { adminApi } from '../../api/client' import { useInstanceSettings } from './useInstanceSettings' -import { bypassChip, bypassOffer, useRangeBypass, type RangeWarning, type TrekRangeBypass } from './useRangeBypass' +import { type RangeWarning } from './useRangeBypass' import { deriveCaps } from './pluginCaps' import PluginPoiCategoryList from './PluginPoiCategoryList' -import { usePluginStore } from '../../store/pluginStore' import { useTranslation } from '../../i18n' -import { useToast } from '../shared/Toast' import ConfirmDialog from '../shared/ConfirmDialog' import ToggleSwitch from '../Settings/ToggleSwitch' import CustomSelect from '../shared/CustomSelect' @@ -28,6 +24,14 @@ import { 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, +} from './plugins/pluginModel' +import { usePluginDetail } from './plugins/usePluginDetail' +import { usePluginsAdmin } from './plugins/usePluginsAdmin' /** * Admin → Plugins (#plugins). A full plugin-management surface: a segmented @@ -38,179 +42,6 @@ import { POPOVER } from '../Packing/packingPopoverStyles' * tile; the security section at the bottom explains the model honestly. */ -interface PluginDep { id: string; version: string } -interface VersionMismatch { id: string; wanted: string; installed: string } -type DependencyStatus = 'ok' | 'addonDisabled' | 'missingPlugin' | 'hostIncompatible' -interface PluginDependencies { requiredAddons: string[]; pluginDependencies: PluginDep[] } -interface DependencyIssues { disabledAddons: string[]; missing: PluginDep[]; versionMismatch: VersionMismatch[] } - -interface PluginRow { - id: string - name: string - description: string | null - type: string - icon: string | null - version: string | null - status: string - enabled: number - last_error: string | null - reviewed_at: string | null - source_repo: string | null - permissions: string - capabilities: string - /** The plugin needs OPERATOR-supplied egress hosts (a self-hosted target). */ - operatorEgress?: boolean - /** How many hosts the admin has added — 0 means the plugin can't reach anything yet. */ - egressHostCount?: number - /** How many `scope:'instance'` settings fields the plugin declares — gates the - * "Instance settings" menu item without a per-plugin fetch. */ - instanceSettingsCount?: number - /** How many `scope:'instance'` actions the plugin declares — a plugin with actions - * but no settings fields still needs the menu item to run them. */ - instanceActionsCount?: number - dependencies?: PluginDependencies - dependencyStatus?: DependencyStatus - dependencyIssues?: DependencyIssues - /** The TREK versions the plugin says it supports; null if it never declared any. */ - trekRange?: string | null - /** The TREK this server is running — the server does the semver, the client just shows it. */ - hostVersion?: string - /** Non-null while the plugin runs outside its declared range on the operator's say-so. */ - trekRangeBypassed?: TrekRangeBypass | null - /** The author's signature was verified and their key pinned at install. False means the - * bytes matched the registry's sha256 and nothing more — one fewer guarantee. */ - signed?: boolean - /** Short form of the pinned key, for eyeballing against what the author reads out. */ - keyFingerprint?: string | null - /** Why an update was refused, if one was. `version` is the version that was refused. */ - updateBlock?: { code: string; detail: string | null; version: string | null } | null - /** A deliberate non-latest install paused updates: out of the banner/Update all until resumed. */ - updateHold?: boolean -} -interface RegistryItem { - id: string - name: string - author: string - description: string - repo: string - homepage?: string | null - type: string - /** Lucide icon name from the registry entry; absent → Blocks. */ - icon?: string | null - latest: string | null - minTrekVersion: string | null - /** The latest version's declared TREK range; null on a legacy registry entry. */ - trek?: string | null - hostVersion?: string - /** Whether the LATEST version can be installed on this TREK. Computed server-side. */ - compatible?: boolean - /** Newest installable version — the latest, an older fallback, or null if none fits. */ - latestCompatible?: string | null - reviewedAt: string | null - downloadCount?: number | null - screenshotUrl: string | null - requiredAddons?: string[] - pluginDependencies?: PluginDep[] - /** The latest version ships an author signature and the entry declares a key. */ - signed?: boolean - /** The full key — public, and carried in full because re-trust compares it exactly. */ - authorPublicKey?: string | null -} -/** One published version, with its SERVER-computed compat verdict (the client has no semver). */ -interface VersionInfo { - version: string - publishedAt: string | null - size: number | null - signed: boolean - /** The declared TREK requirement, or null when the entry carries no bounds at all. */ - trek: string | null - compatible: boolean -} -interface RegistryDetail extends RegistryItem { - size: number | null - publishedAt: string | null - /** Every published version, newest first — the version picker's data. */ - versions?: VersionInfo[] - manifest: { - // Optional because a registry may omit an empty list — the detail view has to - // degrade rather than throw halfway through its render. - permissions?: string[] - egress?: string[] - /** The plugin needs OPERATOR-supplied hosts — its egress list is not the whole story. */ - operatorEgress?: boolean - settings?: Array<{ key: string; label: string; inputType: string; scope: string; required: boolean }> - license: string | null - icon: string | null - requiredAddons?: string[] - pluginDependencies?: PluginDep[] - /** Display slice of the manifest's capabilities — drives the same chips as an installed row. */ - capabilities?: { - widget?: { slot?: string } - tripPage?: { replaces?: string[] } - /** Tools the plugin will publish on the MCP server once mcp:tools is granted. */ - mcpTools?: Array<{ name: string; title?: string; description: string }> - /** Explore-pill categories it adds (#1781), checked again before anything draws them. */ - poiCategories?: unknown - } - } | null -} - -/** 409 error-body shape from POST /activate when a dependency blocks activation. */ -interface ActivateErr { - response?: { - status?: number - data?: { code?: string; error?: string; newPermissions?: string[]; newEgress?: string[]; addons?: string[]; missing?: PluginDep[]; versionMismatch?: VersionMismatch[] } - } -} - -/** The server's error envelope. Reading `code` — not the message text — is what lets the - * UI tell a rotated key (overridable) from a signature that doesn't verify (never). */ -function errBody(e: unknown): { error?: string; code?: string } { - return (e as { response?: { data?: { error?: string; code?: string } } })?.response?.data ?? {} -} - -/** The ONE signature refusal an admin may override, because a rotation has a benign - * explanation. SIGNATURE_INVALID / _MISSING / _INCOMPLETE mean the bytes are not what the - * author signed — those get an explanation and no override button at all. */ -const RETRUSTABLE = 'SIGNATURE_KEY_CHANGED' -const SIGNATURE_CODES = [RETRUSTABLE, 'SIGNATURE_INVALID', 'SIGNATURE_MISSING', 'SIGNATURE_INCOMPLETE'] - -/** - * What the signature dialog needs to talk about a plugin — deliberately NOT a PluginRow. - * - * A refusal can land on a plugin that is not installed (a fresh install from Discover, or a - * dependency being downloaded), and those have no row. Keying the dialog off PluginRow meant - * the lookup missed and the refusal silently degraded to a toast — on the very path where an - * admin most often meets these codes for the first time. Name + pinned fingerprint is all the - * dialog ever reads, and both a row and a registry entry can supply that. - */ -type SigSubject = { id: string; name: string; keyFingerprint: string | null } - -/** - * Is a recorded update block still describing the version on offer? - * - * Once the registry offers something NEWER than the version that was refused, the block - * describes an artifact nobody is being offered anymore — so it reads as stale and the - * admin can simply re-attempt (the next install re-verifies and either succeeds or - * re-blocks with fresh values). When the registry is unreachable we can't prove staleness, - * so the block stands: silently dropping the last thing we knew would be the worse failure. - */ -function blockIsCurrent(p: PluginRow, latestVer?: string): boolean { - if (!p.updateBlock) return false - // A block describes a refused REGISTRY update. Once a plugin is sideloaded or dev-linked - // it has left the registry trust model — the running code is whatever the admin supplied, - // and a block about an author signing key says nothing about it. The server clears the - // block on both paths; this makes it impossible to render a stale one regardless. - if (!isRegistrySourced(p.source_repo)) return false - if (!latestVer || !p.updateBlock.version) return true - return latestVer === p.updateBlock.version -} - -type T = (k: string, p?: Record) => string -type TypeFilter = 'all' | 'widget' | 'page' | 'integration' | 'trip-page' -type StatusFilter = 'all' | 'on' | 'off' | 'update' | 'err' -type SortKey = 'name' | 'recent' | 'updates' | 'downloads' - // Runtime health → dot colour on the icon tile. const HEALTH: Record = { active: 'bg-success', @@ -221,79 +52,6 @@ const HEALTH: Record = { incompatible: 'bg-warning', } -// Known permissions → human-readable i18n key; unknown ones render as raw code. -// Generated from the host's protocol/envelope.ts by -// server/scripts/gen-plugin-facts.ts - this used to be a hand-kept fourth copy. -const PERM_KEYS = PLUGIN_PERMISSIONS - -const KNOWN_TYPES = ['widget', 'page', 'integration', 'trip-page'] - -function isNewer(a: string, b: string): boolean { - const nums = (v: string) => v.split('-')[0].split('.').map(n => Number.parseInt(n, 10) || 0) - const pa = nums(a), pb = nums(b) - for (let i = 0; i < 3; i++) { - const x = pa[i] || 0, y = pb[i] || 0 - if (x !== y) return x > y - } - return !a.includes('-') && b.includes('-') -} - -function parseJson(raw: string | null | undefined, fallback: T): T { - try { const v = JSON.parse(raw || '') as T; return v ?? fallback } catch { return fallback } -} - -interface DepChip { icon: React.ComponentType<{ size?: number; className?: string }>; label: string; blocked: boolean; warn?: boolean } - -// A plugin's declared dependencies as chips — a required addon (amber when that -// addon is disabled), a plugin dependency (amber when missing / version-mismatched), -// or the TREK version itself (amber when this server has outgrown the plugin's range, -// which is the one blocker the admin cannot fix by flipping something else on). -function deriveDeps(p: PluginRow, t: T): DepChip[] { - const out: DepChip[] = [] - const issues = p.dependencyIssues - if (p.dependencyStatus === 'hostIncompatible') { - out.push({ - icon: AlertTriangle, - label: p.trekRange - ? t('admin.plugins.dep.trekIncompatible', { range: p.trekRange, host: p.hostVersion ?? '?' }) - : t('admin.plugins.dep.trekUnknown'), - blocked: true, - }) - } - const bypassed = bypassChip(p.trekRangeBypassed, t) // TREK_PLUGINS_IGNORE_TREK_RANGE: a warning, not a blocker - if (bypassed) out.push(bypassed) - for (const a of p.dependencies?.requiredAddons ?? []) { - out.push({ icon: Blocks, label: t('admin.plugins.cap.requiresAddon', { addon: a }), blocked: !!issues?.disabledAddons.includes(a) }) - } - for (const d of p.dependencies?.pluginDependencies ?? []) { - const blocked = !!(issues?.missing.some(m => m.id === d.id) || issues?.versionMismatch.some(m => m.id === d.id)) - out.push({ icon: Puzzle, label: t('admin.plugins.cap.dependsOn', { id: d.id, version: d.version }), blocked }) - } - return out -} - -/** - * What the Install button may do for a registry item. - * - * The server already decided compatibility — it owns the semver, and a second - * implementation here would eventually disagree with the install gate and offer a button - * that 400s. This only picks the wording. Note the middle case: when the newest release - * has outrun this TREK but an older one still fits, offer THAT version rather than a dead - * grey button. The plugin is perfectly usable, just not at its newest. - */ -function installOffer(item: RegistryItem, t: T, ignoreTrekRange = false): { blocked: boolean; version?: string; label: string; title?: string; warn?: RangeWarning } { - if (item.compatible !== false) return { blocked: false, label: t('admin.plugins.install') } - const title = item.trek - ? t('admin.plugins.dep.trekIncompatible', { range: item.trek, host: item.hostVersion ?? '?' }) - : t('admin.plugins.dep.trekUnknown') - if (ignoreTrekRange) return bypassOffer(item, t, title) // TREK_PLUGINS_IGNORE_TREK_RANGE: "Install anyway" - if (item.latestCompatible) { - return { blocked: false, version: item.latestCompatible, label: t('admin.plugins.installCompatible', { version: item.latestCompatible }), title } - } - return { blocked: true, label: t('admin.plugins.incompatible'), title } -} - - /** 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. */ @@ -331,20 +89,6 @@ function SideloadedBadge({ t }: { t: T }) { ) } -/** - * Whether a plugin came from the REGISTRY (as opposed to a manual upload or a dev-link). - * - * This is the precedence rule for the trust badges, and it is load-bearing: `signed` - * derives from the pinned author key while sideloaded/dev-linked derive from source_repo, - * so the states are NOT mutually exclusive in the data. A sideloaded plugin genuinely has - * no pinned key, and a naive render would put "Unsigned" *and* "Sideloaded" side by side. - * The source badge wins — it already says something strictly stronger, and doubling up - * dilutes the amber into wallpaper, which is exactly what makes a warning worthless. - */ -function isRegistrySourced(sourceRepo: string | null | undefined): boolean { - return !!sourceRepo && sourceRepo !== 'local:upload' && sourceRepo !== 'local:link' -} - /** * Signed / Unsigned, for registry plugins only (see isRegistrySourced). * @@ -388,431 +132,19 @@ function TypeBadge({ type, t }: { type: string; t: T }) { export default function AdminPluginsPanel() { const { t, locale } = useTranslation() - const toast = useToast() - const [runtimeOn, setRuntimeOn] = useState(false) - const [devLink, setDevLink] = useState(false) // dev-link enabled server-side (TREK_PLUGINS_DEV_LINK) - const [ignoreTrekRange, setIgnoreTrekRange] = useState(false) // TREK_PLUGINS_IGNORE_TREK_RANGE set server-side - const bypass = useRangeBypass() // its warning dialog/sheet state — shared with the other shell - const [linkPath, setLinkPath] = useState('') - const [plugins, setPlugins] = useState([]) - const [loading, setLoading] = useState(true) - const [error, setError] = useState(false) - const [busy, setBusy] = useState(null) - const [view, setView] = useState<'installed' | 'discover'>('installed') - const [registry, setRegistry] = useState(null) - const [latest, setLatest] = useState>({}) - // The registry entry per id — the re-trust dialog needs the NEW author key from it (in - // full: the server's equality check is exact), and the Discover/consent copy needs `signed`. - const [regById, setRegById] = useState>({}) - // Open when a signature check refused an install/update. Carries the code, so the dialog - // knows whether an override may even be offered. - const [signatureBlock, setSignatureBlock] = useState<{ subject: SigSubject; code: string; detail: string | null } | null>(null) - const [retrusting, setRetrusting] = useState(false) - const [detailFor, setDetailFor] = useState(null) - const [errorsFor, setErrorsFor] = useState<{ id: string; rows: Array<{ ts: string; level: string; message: string }> } | null>(null) - const [egressFor, setEgressFor] = useState<{ id: string; supported: boolean; hosts: string[] } | null>(null) - // The admin-owned scope:'instance' settings form — shared logic with the phone shell. - const settings = useInstanceSettings() - const [egressDraft, setEgressDraft] = useState('') - const [egressSaving, setEgressSaving] = useState(false) - const [egressError, setEgressError] = useState('') - const [confirmUninstall, setConfirmUninstall] = useState(null) - // The version picker for an INSTALLED plugin ("Change version…"); versions land async - // from the registry detail endpoint, which computes each version's compat verdict. - const [versionPicker, setVersionPicker] = useState<{ plugin: PluginRow; versions: VersionInfo[] | null; failed: boolean } | null>(null) - // A picked version OLDER than the installed one — held here until the admin consents - // to the data risk (the plugin's data dir stays; older code may not understand it). - const [confirmDowngrade, setConfirmDowngrade] = useState<{ plugin: PluginRow; version: string } | null>(null) - // A QUEUE, not one slot: "Update All" can produce several re-consent prompts — - // each must be shown, not silently overwritten by the last one. - const [consentQueue, setConsentQueue] = useState>([]) - // Open when enabling a plugin is blocked by missing/outdated plugin dependencies. - const [depResolve, setDepResolve] = useState<{ plugin: PluginRow; missing: PluginDep[]; versionMismatch: VersionMismatch[] } | null>(null) - const [menu, setMenu] = useState(null) - - // Toolbar state. - const [q, setQ] = useState('') - const [typeFilter, setTypeFilter] = useState('all') - const [statusFilter, setStatusFilter] = useState('all') - const [sort, setSort] = useState('name') - - // 'updates' only ranks installed plugins, 'downloads' only the registry — snap - // the key back to name when switching tabs so the dropdown never carries a label - // for an option the active tab can't offer. - useEffect(() => { - if (view === 'discover' && sort === 'updates') setSort('name') - else if (view === 'installed' && sort === 'downloads') setSort('name') - }, [view, sort]) - - // Sideload upload: drag a plugin .zip onto the panel or use the toolbar button. - const [dragActive, setDragActive] = useState(false) - const uploadInputRef = useRef(null) - const dragDepth = useRef(0) - - // Index the registry once per fetch: the version map the update badges read, plus the - // whole entry (author key + signed flag) the trust badges and re-trust dialog need. - // The map holds latestCompatible — the newest version THIS TREK can install (computed - // server-side) — not the absolute latest, so the banner never counts an update the - // update endpoint would refuse. - const indexRegistry = (items: RegistryItem[]) => { - const vers: Record = {} - const byId: Record = {} - items.forEach((i) => { byId[i.id] = i; if (i.latestCompatible) vers[i.id] = i.latestCompatible }) - setLatest(vers) - setRegById(byId) - } - - const refresh = () => { - // Keep the app-wide active-plugin store in sync so widget/hero/tab consumers - // (e.g. the dashboard) reflect an activate/deactivate without a full reload (F5). - void usePluginStore.getState().loadPlugins() - adminApi.plugins() - .then((d: { enabled: boolean; devLink?: boolean; ignoreTrekRange?: boolean; plugins: PluginRow[] }) => { - setRuntimeOn(!!d.enabled) - setDevLink(!!d.devLink) - setIgnoreTrekRange(!!d.ignoreTrekRange) - setPlugins(d.plugins || []) - if ((d.plugins || []).length) { - adminApi.pluginBrowse().then(indexRegistry).catch(() => {}) - } - }) - .catch(() => setError(true)) - .finally(() => setLoading(false)) - } - useEffect(refresh, []) - - // A signature refusal is not an ordinary "action failed": it is the one class of error - // where WHICH failure it was decides what the admin may do about it. Route it to the - // dialog off the CODE, never off the message text. Returns true when handled. - // - // The subject is resolved from wherever it can be — the installed row, else the registry - // entry (a plugin being INSTALLED has no row yet), else the bare id. A signature code - // ALWAYS opens the dialog: falling back to a toast is what used to hide an invalid - // signature on a fresh install behind the same treatment as a network blip. - const routeSignatureError = (id: string, code?: string, detail?: string): boolean => { - if (!code || !SIGNATURE_CODES.includes(code)) return false - const row = plugins.find(p => p.id === id) - const subject: SigSubject = row - ? { id, name: row.name, keyFingerprint: row.keyFingerprint ?? null } - // Not installed: no pinned key exists, so SIGNATURE_KEY_CHANGED cannot arise and the - // dialog shows the no-override explanation — which is exactly right. - : { id, name: regById[id]?.name ?? id, keyFingerprint: null } - setSignatureBlock({ subject, code, detail: detail ?? null }) - return true - } - - const act = async (id: string, fn: () => Promise, ok: string) => { - setBusy(id); setMenu(null) - try { await fn(); toast.success(ok) } - catch (e) { - const { error, code } = errBody(e) - if (!routeSignatureError(id, code, error)) toast.error(error || t('admin.plugins.actionError')) - } - finally { setBusy(null); refresh() } - } - - const openDiscover = () => { - setView('discover') - if (!registry) { - adminApi.pluginBrowse() - .then((items: RegistryItem[]) => { setRegistry(items); indexRegistry(items) }) - .catch(() => setRegistry([])) - } - } - // The rescan/reload button rediscovers locally-installed plugins AND force-pulls - // the remote registry (bypassing the 30-min server cache + GitHub's CDN), so a - // just-published plugin shows up right away instead of up to ~35 min later. - const rescan = () => act('__rescan', async () => { - await adminApi.pluginRescan() - const items: RegistryItem[] = await adminApi.pluginBrowse(true) - setRegistry(items) - indexRegistry(items) - }, t('admin.plugins.rescanned')) - - // Sideload a plugin archive (installs INACTIVE — the admin still consents on activation). - const uploadPlugin = async (file: File) => { - setBusy('__upload'); setMenu(null) - try { - const res = await adminApi.pluginUpload(file) - setView('installed') - toast.success(t('admin.plugins.uploaded', { name: res.id })) - bypass.notice(res.id, res.trekRangeBypassed) - } catch (e) { - toast.error((e as { response?: { data?: { error?: string } } })?.response?.data?.error || t('admin.plugins.actionError')) - } finally { - setBusy(null); refresh() - } - } - const pickUpload = () => uploadInputRef.current?.click() - const onDragEnter = (e: DragEvent) => { - if (!runtimeOn || !Array.from(e.dataTransfer.types).includes('Files')) return - e.preventDefault(); dragDepth.current++; setDragActive(true) - } - const onDragLeave = () => { if (--dragDepth.current <= 0) { dragDepth.current = 0; setDragActive(false) } } - const onDrop = (e: DragEvent) => { - e.preventDefault(); dragDepth.current = 0; setDragActive(false) - if (!runtimeOn) return - const f = e.dataTransfer.files?.[0] - if (f) void uploadPlugin(f) - } - const openEgress = (id: string) => { - setMenu(null) - setEgressDraft(''); setEgressError('') - adminApi.pluginEgressHosts(id) - .then(d => setEgressFor({ id, supported: d.supported, hosts: d.hosts })) - .catch(() => setEgressFor({ id, supported: false, hosts: [] })) - } - - // Saving RE-SPAWNS the plugin: the child's egress guard is installed once at init and - // a second init is refused, so a live child's allow-list can never be widened in place. - const saveEgress = async (hosts: string[]) => { - if (!egressFor) return - setEgressSaving(true); setEgressError('') - try { - const d = await adminApi.pluginSetEgressHosts(egressFor.id, hosts) - setEgressFor({ ...egressFor, hosts: d.hosts }) - setEgressDraft('') - } catch (e) { - const err = e as { response?: { data?: { error?: string } } } - setEgressError(err.response?.data?.error || t('common.error')) - } finally { - setEgressSaving(false) - } - } - - const openInstanceSettings = (p: { id: string; status: string }) => { - setMenu(null) - settings.open(p.id, p.status === 'active') - } - - const openErrors = (id: string) => { - setMenu(null) - adminApi.pluginErrors(id) - .then((d: { errors: Array<{ ts: string; level: string; message: string }> }) => setErrorsFor({ id, rows: d.errors })) - .catch(() => setErrorsFor({ id, rows: [] })) - } - - // A held plugin (deliberate non-latest install) is deliberately NOT an update candidate: - // the admin just rolled it back, and the banner nagging them straight back would make - // the rollback fight the UI. The row carries its own "paused" marker + resume instead. - const updateAvailable = (p: PluginRow) => !!(p.version && !p.updateHold && latest[p.id] && isNewer(latest[p.id], p.version)) - const resumeUpdates = (p: PluginRow) => act(p.id, () => adminApi.pluginResumeUpdates(p.id), t('admin.plugins.updatesResumed')) - // A newer version exists but this TREK can't install it (and it isn't the one on offer): - // said passively on the row, so the admin learns a TREK upgrade unlocks it instead of - // wondering why no update shows. Null when the registry gives no range to point at. - const newerIncompatible = (p: PluginRow): { version: string; range: string } | null => { - const reg = regById[p.id] - if (!reg?.latest || !p.version || !isNewer(reg.latest, p.version)) return null - if (latest[p.id] === reg.latest) return null - const range = reg.trek ?? (reg.minTrekVersion ? `>=${reg.minTrekVersion}` : null) - return range ? { version: reg.latest, range } : null - } - // `warn` is the pre-install confirm for an entry the registry already flagged as - // incompatible; an artifact whose OWN manifest turns out to be out of range (the index - // was only a pre-download filter) is caught by the marker on the response instead. - const install = (id: string, version?: string, warn?: RangeWarning) => bypass.guard(warn, () => - act(id, () => adminApi.pluginInstall(id, version ? { version } : undefined).then(r => { if (!warn) bypass.notice(id, r?.trekRangeBypassed) }), t('admin.plugins.installed'))) - const restart = (id: string) => act(id, async () => { await adminApi.pluginDeactivate(id); await adminApi.pluginActivate(id) }, t('admin.plugins.restarted')) - // Dev-link: register a plugin from a local built directory (dev only). Reuses the - // same busy/toast/refresh loop as uploadPlugin; the server gates it. - const linkLocal = async () => { - const p = linkPath.trim() - if (!p) return - setBusy('__link'); setMenu(null) - try { - const res = await adminApi.pluginLink(p) - setView('installed') - setLinkPath('') - toast.success(t('admin.plugins.devLinkLinked', { id: res.id })) - bypass.notice(res.id, res.trekRangeBypassed) - } catch (e) { - toast.error((e as { response?: { data?: { error?: string } } })?.response?.data?.error || t('admin.plugins.actionError')) - } finally { - setBusy(null); refresh() - } - } - const installedIds = new Set(plugins.map(p => p.id)) - - // Installed-but-disabled direct deps that enabling `p` will auto-enable first. - const autoEnabledDeps = (p: PluginRow) => - (p.dependencies?.pluginDependencies ?? []) - .map(d => plugins.find(x => x.id === d.id)) - .filter((x): x is PluginRow => !!x && x.enabled === 0) - .map(x => x.name) - - // Shared handling for a failed activation: route each 409 code to the right fix - // (consent dialog, download-dependency dialog, or a clear toast). - const onActivateError = (p: PluginRow, e: ActivateErr) => { - const d = e?.response?.data - if (e?.response?.status === 409 && d?.code === 'CONSENT_REQUIRED') { - setConsentQueue(qq => [...qq, { plugin: p, version: latest[p.id] ?? p.version ?? '', newPermissions: d.newPermissions ?? [], newEgress: d.newEgress ?? [] }]) - } else if (e?.response?.status === 409 && d?.code === 'ADDON_DISABLED') { - toast.error(t('admin.plugins.dep.addonDisabledToast', { count: (d.addons ?? []).length, addons: (d.addons ?? []).join(', ') })) - } else if (e?.response?.status === 409 && d?.code === 'DEPENDENCY_MISSING') { - setDepResolve({ plugin: p, missing: d.missing ?? [], versionMismatch: d.versionMismatch ?? [] }) - } else { - // DEPENDENCY_CYCLE and everything else surface their server message. - toast.error(d?.error || t('admin.plugins.actionError')) - } - } - - const attemptActivate = (p: PluginRow) => { - const cascaded = autoEnabledDeps(p) - return adminApi.pluginActivate(p.id) - .then(() => { - toast.success(t('admin.plugins.activated')) - if (cascaded.length) toast.success(t('admin.plugins.dep.autoEnabled', { count: cascaded.length, plugins: cascaded.join(', ') })) - setDepResolve(null) - }) - .catch((e: ActivateErr) => onActivateError(p, e)) - } - - // Enable/disable a plugin. Re-enabling one whose update widened its permissions - // must NOT grant them silently (409 CONSENT_REQUIRED → consent dialog); a disabled - // required addon or a missing plugin dependency (409 ADDON_DISABLED / - // DEPENDENCY_MISSING) routes to the right remedy. - const toggle = (p: PluginRow) => { - if (busy === p.id) return - if (p.enabled === 1) { void act(p.id, () => adminApi.pluginDeactivate(p.id), t('admin.plugins.deactivated')); return } - setBusy(p.id); setMenu(null) - attemptActivate(p).finally(() => { setBusy(null); refresh() }) - } - - // Download a missing/outdated plugin dependency (latest compatible for its range, - // transitively), then retry enabling the plugin that needed it. - const resolveDependency = (parent: PluginRow, depId: string, constraint?: string) => { - if (busy === parent.id) return - setBusy(parent.id) - adminApi.pluginInstall(depId, { constraint, withDependencies: true }) - .then((r: { installed?: string[]; requiredAddons?: string[]; trekRangeBypassed?: TrekRangeBypass | null }) => { - toast.success(t('admin.plugins.dep.downloaded', { id: depId })) - if (r?.requiredAddons?.length) toast.error(t('admin.plugins.dep.addonDisabledToast', { count: r.requiredAddons.length, addons: r.requiredAddons.join(', ') })) - bypass.notice(depId, r?.trekRangeBypassed) - return attemptActivate(parent) - }) - // The DEPENDENCY is what's being downloaded, so a signature refusal here is about the - // dependency's author, not the parent's — route it under depId before falling through - // to the activation-error handling, which knows nothing about signature codes. - .catch((e: ActivateErr) => { - const { error, code } = errBody(e) - if (!routeSignatureError(depId, code, error)) onActivateError(parent, e) - }) - .finally(() => { setBusy(null); refresh() }) - } - - // `version` pins the exact version to install (rollback / explicit switch); omitted, - // the server resolves the newest TREK-compatible version itself. - const runUpdate = (p: PluginRow, version?: string) => { - setBusy(p.id); setMenu(null) - return adminApi.pluginUpdate(p.id, version) - .then((r: { version: string; activated: boolean; newPermissions: string[]; newEgress: string[]; trekRangeBypassed?: TrekRangeBypass | null }) => { - if (r.activated || (r.newPermissions.length === 0 && r.newEgress.length === 0)) toast.success(t('admin.plugins.updated')) - else setConsentQueue(qq => [...qq, { plugin: p, version: r.version, newPermissions: r.newPermissions, newEgress: r.newEgress }]) - bypass.notice(p.name, r.trekRangeBypassed) - }) - .catch(e => { - const { error, code } = errBody(e) - if (!routeSignatureError(p.id, code, error)) toast.error(error || t('admin.plugins.actionError')) - }) - .finally(() => { setBusy(null); refresh() }) - } - - // Confirm a key rotation: re-pin the new key AND update, in one server call. There is no - // follow-up /update — a re-pin that waited for a second call would leave the plugin - // pinned to a key no install had ever been verified against if that call never came. - const confirmRetrust = (id: string, version: string, publicKey: string) => { - setRetrusting(true) - adminApi.pluginRetrust(id, version, publicKey) - .then((r: { version: string; activated: boolean; newPermissions: string[]; newEgress: string[] }) => { - setSignatureBlock(null) - // A re-trusted update widening permissions still needs consent — re-trusting a - // signing key says nothing about what the new code is allowed to do. Re-trust only - // ever fires for an INSTALLED plugin, so the row is there to consent against. - const p = plugins.find(x => x.id === id) - if (p && !r.activated && (r.newPermissions.length > 0 || r.newEgress.length > 0)) { - setConsentQueue(qq => [...qq, { plugin: p, version: r.version, newPermissions: r.newPermissions, newEgress: r.newEgress }]) - } else toast.success(t('admin.plugins.retrusted')) - }) - .catch(e => toast.error(errBody(e).error || t('admin.plugins.actionError'))) - .finally(() => { setRetrusting(false); refresh() }) - } - - // "Change version…" on an installed row: fetch the per-version list (with the server's - // compat verdicts) and open the picker. The fetch is keyed to the plugin so a stale - // response can't populate a picker that was reopened for another row. - const openVersionPicker = (p: PluginRow) => { - setMenu(null) - setVersionPicker({ plugin: p, versions: null, failed: false }) - adminApi.pluginDetail(p.id) - .then((d: RegistryDetail) => setVersionPicker(cur => (cur && cur.plugin.id === p.id ? { ...cur, versions: d.versions ?? [] } : cur))) - .catch(() => setVersionPicker(cur => (cur && cur.plugin.id === p.id ? { ...cur, failed: true } : cur))) - } - - // Switching DOWN keeps the plugin's data dir, which the older code may not understand — - // that asks for explicit consent first. Any other switch is the ordinary update path. - const pickVersion = (version: string) => { - if (!versionPicker) return - const p = versionPicker.plugin - setVersionPicker(null) - if (p.version && isNewer(p.version, version)) setConfirmDowngrade({ plugin: p, version }) - else void runUpdate(p, version) - } - - // eslint-disable-next-line react-hooks/exhaustive-deps - const updatable = useMemo(() => plugins.filter(updateAvailable), [plugins, latest]) - - // One after another: busy is a single slot, so parallel updates would leave the - // other rows clickable mid-install and refresh once per plugin. - const updateAll = async () => { - for (const p of updatable) await runUpdate(p) - } - - // Installed list after search / type / status filters + sort. - const shownInstalled = useMemo(() => { - const term = q.trim().toLowerCase() - let rows = plugins.filter(p => { - const matchesText = !term || `${p.name} ${p.description ?? ''}`.toLowerCase().includes(term) - const matchesType = typeFilter === 'all' || p.type === typeFilter - const st = statusFilter === 'all' ? true - : statusFilter === 'on' ? p.enabled === 1 && p.status !== 'error' - : statusFilter === 'off' ? p.enabled === 0 - : statusFilter === 'update' ? updateAvailable(p) - : p.status === 'error' - return matchesText && matchesType && st - }) - rows = [...rows].sort((a, b) => { - if (sort === 'updates') { - const ua = updateAvailable(a) ? 0 : 1, ub = updateAvailable(b) ? 0 : 1 - if (ua !== ub) return ua - ub - } - return a.name.localeCompare(b.name) - }) - return rows - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [plugins, q, typeFilter, statusFilter, sort, latest]) - - // Registry list after search / type filter. - const shownRegistry = useMemo(() => { - if (!registry) return null - const term = q.trim().toLowerCase() - let items = registry.filter(r => { - const matchesText = !term || `${r.name} ${r.author} ${r.description}`.toLowerCase().includes(term) - const matchesType = typeFilter === 'all' || r.type === typeFilter - return matchesText && matchesType - }) - items = [...items].sort((a, b) => { - if (sort === 'downloads') return (b.downloadCount ?? 0) - (a.downloadCount ?? 0) || a.name.localeCompare(b.name) - if (sort === 'recent') return (Date.parse(b.reviewedAt ?? '') || 0) - (Date.parse(a.reviewedAt ?? '') || 0) || a.name.localeCompare(b.name) - return a.name.localeCompare(b.name) - }) - return items - }, [registry, q, typeFilter, sort]) - - const anyFilter = q.trim() !== '' || typeFilter !== 'all' || statusFilter !== 'all' - const ready = runtimeOn && !loading && !error - // A question opened from the detail view (the range warning, a signature refusal, the - // consent gate) sits on top of it; Escape and the backdrop leave the detail alone meanwhile. - const detailBlocked = !!bypass.copy || !!signatureBlock || consentQueue.length > 0 + const { + anyFilter, approveConsent, askUninstall, busy, bypass, confirmDowngrade, confirmUninstall, consentQueue, + consentUnsigned, deferConsent, depResolve, detailBlocked, detailFor, devLink, downgradeConfirmed, + downloadDependency, dragActive, egressDraft, egressError, egressFor, egressSaving, error, errorsFor, + ignoreTrekRange, install, installedIds, latest, linkLocal, linkPath, loading, menu, newerIncompatible, onDragEnter, + onDragLeave, onDrop, openDiscover, openEgress, openErrors, openInstanceSettings, openVersionPicker, pickUpload, + pickVersion, plugins, q, ready, regById, registry, rescan, restart, resumeUpdates, retrustBlocked, retrusting, + reviewBlock, runtimeOn, runUpdate, saveEgress, setConfirmDowngrade, setConfirmUninstall, setDepResolve, + setDetailFor, setEgressDraft, setEgressFor, setErrorsFor, setLinkPath, setMenu, setQ, setSignatureBlock, setSort, + setStatusFilter, settings, setTypeFilter, setVersionPicker, setView, shownInstalled, shownRegistry, signatureBlock, + sort, statusFilter, toggle, typeFilter, uninstallConfirmed, updatable, updateAll, updateAvailable, uploadInputRef, + uploadPlugin, versionPicker, view, + } = usePluginsAdmin() return (
runUpdate(p)} onRestart={() => restart(p.id)} onChangeVersion={() => openVersionPicker(p)} onResume={() => void resumeUpdates(p)} - onReviewBlock={() => setSignatureBlock({ - subject: { id: p.id, name: p.name, keyFingerprint: p.keyFingerprint ?? null }, - code: p.updateBlock!.code, detail: p.updateBlock!.detail, - })} + onReviewBlock={() => reviewBlock(p)} onErrors={() => openErrors(p.id)} onEgress={() => openEgress(p.id)} onSettings={() => openInstanceSettings(p)} - onUninstall={() => { setMenu(null); setConfirmUninstall(p) }} /> + onUninstall={() => askUninstall(p)} /> ))} )} @@ -1024,10 +353,7 @@ export default function AdminPluginsPanel() { setConfirmUninstall(null)} - onConfirm={async () => { - const p = confirmUninstall!; setConfirmUninstall(null) - await act(p.id, () => adminApi.pluginUninstall(p.id, true), t('admin.plugins.uninstalled')) - }} + onConfirm={uninstallConfirmed} title={t('admin.plugins.uninstallTitle')} message={t('admin.plugins.uninstallBody')} /> @@ -1040,7 +366,7 @@ export default function AdminPluginsPanel() { setConfirmDowngrade(null)} - onConfirm={() => { const d = confirmDowngrade!; setConfirmDowngrade(null); void runUpdate(d.plugin, d.version) }} + onConfirm={downgradeConfirmed} title={t('admin.plugins.downgradeTitle')} message={t('admin.plugins.downgradeBody', { from: confirmDowngrade?.plugin.version ?? '', to: confirmDowngrade?.version ?? '' })} confirmLabel={t('admin.plugins.downgradeConfirm')} @@ -1049,7 +375,7 @@ export default function AdminPluginsPanel() { {signatureBlock && ( confirmRetrust(signatureBlock.subject.id, version, publicKey)} + onRetrust={retrustBlocked} onClose={() => setSignatureBlock(null)} /> )} @@ -1057,28 +383,16 @@ export default function AdminPluginsPanel() { {consentQueue[0] && ( { - const c = consentQueue[0]; setConsentQueue(qq => qq.slice(1)) - // consent:true — the ONLY path that may widen a plugin's granted rights. - await act(c.plugin.id, () => adminApi.pluginActivate(c.plugin.id, true), t('admin.plugins.updated')) - }} - onLater={() => { setConsentQueue(qq => qq.slice(1)); toast.success(t('admin.plugins.updateKeptOff')) }} + unsigned={consentUnsigned} + onApprove={approveConsent} + onLater={deferConsent} /> )} {depResolve && ( resolveDependency(depResolve.plugin, depId, constraint)} + onDownload={downloadDependency} onClose={() => setDepResolve(null)} /> )} @@ -1086,17 +400,6 @@ export default function AdminPluginsPanel() { ) } -function statusLabel(s: StatusFilter, t: T): string { - return s === 'all' ? t('admin.plugins.allStatuses') : s === 'on' ? t('admin.plugins.status.active') - : s === 'off' ? t('admin.plugins.stateOff') : s === 'update' ? t('admin.plugins.filterUpdate') : t('admin.plugins.status.error') -} -function sortLabel(s: SortKey, t: T): string { - return s === 'name' ? t('admin.plugins.sortName') - : s === 'recent' ? t('admin.plugins.sortRecent') - : s === 'downloads' ? t('admin.plugins.sortDownloads') - : t('admin.plugins.sortUpdates') -} - /** An icon action on the card's head band, named by its tooltip. */ function BandButton({ label, onClick, disabled, children }: { label: string; onClick: () => void; disabled?: boolean; children: ReactNode }) { return ( @@ -1485,14 +788,6 @@ function RegistryGrid({ items, onInstall, onOpenDetail, busy, t, installedIds, f ) } -// 1234 -> "1.2k" — GitHub-style compact download counts for the browse cards. -// The M threshold sits at the k-rounding boundary so 999 950 is "1M", not "1000k". -function formatCompactCount(n: number): string { - if (n >= 999_500) return `${Math.round(n / 100_000) / 10}M` - if (n >= 1000) return `${Math.round(n / 100) / 10}k` - return String(n) -} - // A permission rendered human-readable when known, else as its raw code. function PermLabel({ perm, t }: { perm: string; t: T }) { return PERM_KEYS.includes(perm) @@ -1758,16 +1053,7 @@ function PluginDetailModal({ item, installed, busy, onInstall, onClose, t, local item: RegistryItem; installed: boolean; busy: string | null; ignoreTrekRange: boolean; blocked: boolean onInstall: (id: string, version?: string, warn?: RangeWarning) => void; onClose: () => void; t: T; locale: string }) { - const [detail, setDetail] = useState(null) - const [failed, setFailed] = useState(false) - - useEffect(() => { - let alive = true - adminApi.pluginDetail(item.id) - .then((d: RegistryDetail) => { if (alive) setDetail(d) }) - .catch(() => { if (alive) setFailed(true) }) - return () => { alive = false } - }, [item.id]) + const { detail, failed } = usePluginDetail(item.id) const manifest = detail?.manifest ?? null const permissions = manifest?.permissions ?? [] @@ -2058,17 +1344,8 @@ function SignatureBlockDialog({ data, entry, busy, t, onRetrust, onClose }: { onRetrust: (version: string, publicKey: string) => void onClose: () => void }) { - const canRetrust = data.code === RETRUSTABLE - const newKey = entry?.authorPublicKey ?? null - const version = entry?.latest ?? null - // Only offer the override when we actually hold the new key + version to send: the - // request carries both, and the server compares the key exactly. - const offerRetrust = canRetrust && !!newKey && !!version - - const bodyKey = data.code === RETRUSTABLE ? 'admin.plugins.sig.keyChangedBody' - : data.code === 'SIGNATURE_MISSING' ? 'admin.plugins.sig.missingBody' - : data.code === 'SIGNATURE_INCOMPLETE' ? 'admin.plugins.sig.incompleteBody' - : 'admin.plugins.sig.invalidBody' + const { canRetrust, newKey, retrust, bodyKey } = signatureBlockView(data.code, entry) + const offerRetrust = retrust !== null return ( {offerRetrust ? t('admin.plugins.sig.cancel') : t('common.close')} - {offerRetrust && ( - onRetrust(version, newKey)} disabled={busy}> + {retrust && ( + onRetrust(retrust.version, retrust.newKey)} disabled={busy}> {busy && }{t('admin.plugins.sig.retrustConfirm')} )} @@ -2116,15 +1393,6 @@ function KeyRow({ label, value, highlight }: { label: string; value: string; hig ) } -/** Client-side twin of the server's fingerprint: head…tail of the base64 payload. Display - * only — every equality check that matters happens server-side against the full key. */ -function fingerprint(key: string | null): string | null { - if (!key) return null - const payload = key.trim().split(/\r?\n/).map(l => l.trim()).filter(l => l && !l.startsWith('untrusted comment')).pop() - if (!payload) return null - return payload.length <= 20 ? payload : `${payload.slice(0, 8)}…${payload.slice(-8)}` -} - function UpdateConsentDialog({ data, unsigned, t, onApprove, onLater }: { data: { plugin: PluginRow; version: string; newPermissions: string[]; newEgress: string[] } unsigned: boolean @@ -2183,10 +1451,7 @@ function DependencyResolveDialog({ data, t, busy, installedIds, onDownload, onCl t: T; busy: boolean; installedIds: Set onDownload: (depId: string, constraint?: string) => void; onClose: () => void }) { - const rows: Array<{ id: string; constraint: string; installed?: string }> = [ - ...data.missing.map(d => ({ id: d.id, constraint: d.version })), - ...data.versionMismatch.map(d => ({ id: d.id, constraint: d.wanted, installed: d.installed })), - ] + const rows = dependencyRows(data) return ( ) => (params ? `${key} ${JSON.stringify(params)}` : key); + +function row(over: Partial = {}): PluginRow { + return { + id: 'alpha', + name: 'Alpha', + description: null, + type: 'widget', + icon: null, + version: '1.0.0', + status: 'active', + enabled: 1, + last_error: null, + reviewed_at: null, + source_repo: 'org/alpha', + permissions: '[]', + capabilities: '{}', + ...over, + }; +} + +function item(over: Partial = {}): RegistryItem { + return { + id: 'alpha', + name: 'Alpha', + author: 'someone', + description: 'does things', + repo: 'org/alpha', + type: 'widget', + latest: '1.0.0', + minTrekVersion: null, + reviewedAt: null, + screenshotUrl: null, + ...over, + }; +} + +describe('pluginModel', () => { + it('FE-ADMIN-PLUGIN-MODEL-001: isNewer compares three numeric parts and ranks a release over its prerelease', () => { + expect(isNewer('1.2.0', '1.1.9')).toBe(true); + expect(isNewer('1.10.0', '1.9.0')).toBe(true); + expect(isNewer('1.0.0', '1.0.0')).toBe(false); + expect(isNewer('1.0.0', '1.0.0-beta.1')).toBe(true); + expect(isNewer('1.0.0-beta.1', '1.0.0')).toBe(false); + }); + + it('FE-ADMIN-PLUGIN-MODEL-002: parseJson falls back on null, empty and broken input', () => { + expect(parseJson('["a"]', [])).toEqual(['a']); + expect(parseJson(null, ['x'])).toEqual(['x']); + expect(parseJson('null', ['x'])).toEqual(['x']); + expect(parseJson('{oops', {})).toEqual({}); + }); + + it('FE-ADMIN-PLUGIN-MODEL-003: errBody reads the server envelope and tolerates anything else', () => { + expect(errBody({ response: { data: { error: 'nope', code: 'X' } } })).toEqual({ error: 'nope', code: 'X' }); + expect(errBody(new Error('boom'))).toEqual({}); + expect(errBody(undefined)).toEqual({}); + }); + + it('FE-ADMIN-PLUGIN-MODEL-004: only registry plugins count as registry sourced', () => { + expect(isRegistrySourced('org/alpha')).toBe(true); + expect(isRegistrySourced('local:upload')).toBe(false); + expect(isRegistrySourced('local:link')).toBe(false); + expect(isRegistrySourced(null)).toBe(false); + }); + + it('FE-ADMIN-PLUGIN-MODEL-005: an update block stands until the registry offers something else', () => { + const blocked = row({ updateBlock: { code: 'SIGNATURE_INVALID', detail: null, version: '1.1.0' } }); + expect(blockIsCurrent(row(), '1.1.0')).toBe(false); + expect(blockIsCurrent(blocked, '1.1.0')).toBe(true); + expect(blockIsCurrent(blocked, undefined)).toBe(true); + expect(blockIsCurrent(blocked, '1.2.0')).toBe(false); + expect(blockIsCurrent({ ...blocked, source_repo: 'local:upload' }, '1.1.0')).toBe(false); + }); + + it('FE-ADMIN-PLUGIN-MODEL-006: formatCompactCount and fingerprint shorten for display', () => { + expect(formatCompactCount(999)).toBe('999'); + expect(formatCompactCount(1234)).toBe('1.2k'); + expect(formatCompactCount(999_950)).toBe('1M'); + expect(fingerprint(null)).toBeNull(); + expect(fingerprint('untrusted comment: key\nshort')).toBe('short'); + expect(fingerprint('untrusted comment: key\nABCDEFGHIJKLMNOPQRSTUVWXYZ')).toBe('ABCDEFGH…STUVWXYZ'); + }); + + it('FE-ADMIN-PLUGIN-MODEL-007: installOffer installs, falls back to a compatible version, or refuses', () => { + expect(installOffer(item(), t)).toEqual({ blocked: false, label: 'admin.plugins.install' }); + const incompatible = item({ compatible: false, trek: '>=9', hostVersion: '4.0.0' }); + expect(installOffer({ ...incompatible, latestCompatible: '0.9.0' }, t)).toMatchObject({ + 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' }); + }); + + it('FE-ADMIN-PLUGIN-MODEL-008: deriveDeps marks the blockers among the declared dependencies', () => { + const chips = deriveDeps( + row({ + dependencyStatus: 'hostIncompatible', + trekRange: '>=9', + hostVersion: '4.0.0', + dependencies: { requiredAddons: ['budget'], pluginDependencies: [{ id: 'beta', version: '^1' }] }, + dependencyIssues: { disabledAddons: ['budget'], missing: [], versionMismatch: [] }, + }), + t + ); + expect(chips.map((c) => c.blocked)).toEqual([true, true, false]); + expect(chips[2]!.label).toBe('admin.plugins.cap.dependsOn {"id":"beta","version":"^1"}'); + }); + + it('FE-ADMIN-PLUGIN-MODEL-009: a held plugin or an older offer is no update', () => { + const latest = { alpha: '1.1.0' }; + expect(isUpdateAvailable(row(), latest)).toBe(true); + expect(isUpdateAvailable(row({ updateHold: true }), latest)).toBe(false); + expect(isUpdateAvailable(row({ version: '1.1.0' }), latest)).toBe(false); + expect(isUpdateAvailable(row(), {})).toBe(false); + }); + + it('FE-ADMIN-PLUGIN-MODEL-010: filterInstalled searches, filters by status and puts updates first', () => { + const plugins = [ + row({ id: 'c', name: 'Charlie', enabled: 0 }), + row({ id: 'b', name: 'Bravo', description: 'maps' }), + row({ id: 'a', name: 'Alpha', status: 'error' }), + ]; + const base = { q: '', typeFilter: 'all' as const, statusFilter: 'all' as const, sort: 'name' as const }; + expect(filterInstalled(plugins, base, {}).map((p) => p.id)).toEqual(['a', 'b', 'c']); + expect(filterInstalled(plugins, { ...base, q: 'MAPS' }, {}).map((p) => p.id)).toEqual(['b']); + expect(filterInstalled(plugins, { ...base, statusFilter: 'on' }, {}).map((p) => p.id)).toEqual(['b']); + expect(filterInstalled(plugins, { ...base, statusFilter: 'off' }, {}).map((p) => p.id)).toEqual(['c']); + expect(filterInstalled(plugins, { ...base, statusFilter: 'err' }, {}).map((p) => p.id)).toEqual(['a']); + expect(filterInstalled(plugins, { ...base, sort: 'updates' }, { c: '2.0.0' }).map((p) => p.id)).toEqual([ + 'c', + 'a', + 'b', + ]); + }); + + it('FE-ADMIN-PLUGIN-MODEL-011: filterRegistry searches, filters by type and sorts', () => { + expect(filterRegistry(null, { q: '', typeFilter: 'all', sort: 'name' })).toBeNull(); + const items = [ + item({ id: 'b', name: 'Bravo', downloadCount: 5, reviewedAt: '2026-01-01' }), + item({ id: 'a', name: 'Alpha', type: 'page', downloadCount: 50, reviewedAt: '2025-01-01' }), + ]; + expect(filterRegistry(items, { q: '', typeFilter: 'all', sort: 'name' })!.map((i) => i.id)).toEqual(['a', 'b']); + expect(filterRegistry(items, { q: '', typeFilter: 'all', sort: 'downloads' })!.map((i) => i.id)).toEqual([ + 'a', + 'b', + ]); + expect(filterRegistry(items, { q: '', typeFilter: 'all', sort: 'recent' })!.map((i) => i.id)).toEqual(['b', 'a']); + expect(filterRegistry(items, { q: '', typeFilter: 'page', sort: 'name' })!.map((i) => i.id)).toEqual(['a']); + expect(filterRegistry(items, { q: 'BRAV', typeFilter: 'all', sort: 'name' })!.map((i) => i.id)).toEqual(['b']); + }); + + it('FE-ADMIN-PLUGIN-MODEL-012: signatureBlockView offers a re-trust only for a rotated key it can send', () => { + const entry = item({ authorPublicKey: 'KEY', latest: '2.0.0' }); + expect(signatureBlockView('SIGNATURE_KEY_CHANGED', entry)).toEqual({ + canRetrust: true, + newKey: 'KEY', + retrust: { newKey: 'KEY', version: '2.0.0' }, + bodyKey: 'admin.plugins.sig.keyChangedBody', + }); + expect(signatureBlockView('SIGNATURE_KEY_CHANGED', undefined).retrust).toBeNull(); + expect(signatureBlockView('SIGNATURE_MISSING', entry)).toMatchObject({ + canRetrust: false, + retrust: null, + bodyKey: 'admin.plugins.sig.missingBody', + }); + expect(signatureBlockView('SIGNATURE_INCOMPLETE', entry).bodyKey).toBe('admin.plugins.sig.incompleteBody'); + expect(signatureBlockView('SIGNATURE_INVALID', entry).bodyKey).toBe('admin.plugins.sig.invalidBody'); + }); + + it('FE-ADMIN-PLUGIN-MODEL-013: dependencyRows lists the missing dependencies before the outdated ones', () => { + expect( + dependencyRows({ + missing: [{ id: 'a', version: '^1' }], + versionMismatch: [{ id: 'b', wanted: '^2', installed: '1.0.0' }], + }) + ).toEqual([ + { id: 'a', constraint: '^1' }, + { id: 'b', constraint: '^2', installed: '1.0.0' }, + ]); + }); + + it('FE-ADMIN-PLUGIN-MODEL-014: statusLabel and sortLabel name each choice', () => { + expect(statusLabel('all', t)).toBe('admin.plugins.allStatuses'); + expect(statusLabel('err', t)).toBe('admin.plugins.status.error'); + expect(sortLabel('downloads', t)).toBe('admin.plugins.sortDownloads'); + expect(sortLabel('updates', t)).toBe('admin.plugins.sortUpdates'); + }); +}); diff --git a/client/src/components/Admin/plugins/pluginModel.ts b/client/src/components/Admin/plugins/pluginModel.ts new file mode 100644 index 000000000..8ee820ebd --- /dev/null +++ b/client/src/components/Admin/plugins/pluginModel.ts @@ -0,0 +1,458 @@ +import { PLUGIN_PERMISSIONS } from '@trek/shared'; +import { AlertTriangle, Blocks, Puzzle } from 'lucide-react'; +import type { ComponentType } from 'react'; +import { bypassChip, bypassOffer, type RangeWarning, type TrekRangeBypass } from '../useRangeBypass'; + +export interface PluginDep { + id: string; + version: string; +} +export interface VersionMismatch { + id: string; + wanted: string; + installed: string; +} +type DependencyStatus = 'ok' | 'addonDisabled' | 'missingPlugin' | 'hostIncompatible'; +interface PluginDependencies { + requiredAddons: string[]; + pluginDependencies: PluginDep[]; +} +interface DependencyIssues { + disabledAddons: string[]; + missing: PluginDep[]; + versionMismatch: VersionMismatch[]; +} + +export interface PluginRow { + id: string; + name: string; + description: string | null; + type: string; + icon: string | null; + version: string | null; + status: string; + enabled: number; + last_error: string | null; + reviewed_at: string | null; + source_repo: string | null; + permissions: string; + capabilities: string; + /** The plugin needs OPERATOR-supplied egress hosts (a self-hosted target). */ + operatorEgress?: boolean; + /** How many hosts the admin has added: 0 means the plugin can't reach anything yet. */ + egressHostCount?: number; + /** How many `scope:'instance'` settings fields the plugin declares, gates the + * "Instance settings" menu item without a per-plugin fetch. */ + instanceSettingsCount?: number; + /** How many `scope:'instance'` actions the plugin declares, a plugin with actions + * but no settings fields still needs the menu item to run them. */ + instanceActionsCount?: number; + dependencies?: PluginDependencies; + dependencyStatus?: DependencyStatus; + dependencyIssues?: DependencyIssues; + /** The TREK versions the plugin says it supports; null if it never declared any. */ + trekRange?: string | null; + /** The TREK this server is running, the server does the semver, the client just shows it. */ + hostVersion?: string; + /** Non-null while the plugin runs outside its declared range on the operator's say-so. */ + trekRangeBypassed?: TrekRangeBypass | null; + /** The author's signature was verified and their key pinned at install. False means the + * bytes matched the registry's sha256 and nothing more, one fewer guarantee. */ + signed?: boolean; + /** Short form of the pinned key, for eyeballing against what the author reads out. */ + keyFingerprint?: string | null; + /** Why an update was refused, if one was. `version` is the version that was refused. */ + updateBlock?: { code: string; detail: string | null; version: string | null } | null; + /** A deliberate non-latest install paused updates: out of the banner/Update all until resumed. */ + updateHold?: boolean; +} +export interface RegistryItem { + id: string; + name: string; + author: string; + description: string; + repo: string; + homepage?: string | null; + type: string; + /** Lucide icon name from the registry entry; absent → Blocks. */ + icon?: string | null; + latest: string | null; + minTrekVersion: string | null; + /** The latest version's declared TREK range; null on a legacy registry entry. */ + trek?: string | null; + hostVersion?: string; + /** Whether the LATEST version can be installed on this TREK. Computed server-side. */ + compatible?: boolean; + /** Newest installable version: the latest, an older fallback, or null if none fits. */ + latestCompatible?: string | null; + reviewedAt: string | null; + downloadCount?: number | null; + screenshotUrl: string | null; + requiredAddons?: string[]; + pluginDependencies?: PluginDep[]; + /** The latest version ships an author signature and the entry declares a key. */ + signed?: boolean; + /** The full key: public, and carried in full because re-trust compares it exactly. */ + authorPublicKey?: string | null; +} +/** One published version, with its SERVER-computed compat verdict (the client has no semver). */ +export interface VersionInfo { + version: string; + publishedAt: string | null; + size: number | null; + signed: boolean; + /** The declared TREK requirement, or null when the entry carries no bounds at all. */ + trek: string | null; + compatible: boolean; +} +export interface RegistryDetail extends RegistryItem { + size: number | null; + publishedAt: string | null; + /** Every published version, newest first: the version picker's data. */ + versions?: VersionInfo[]; + manifest: { + // Optional because a registry may omit an empty list, the detail view has to + // degrade rather than throw halfway through its render. + permissions?: string[]; + egress?: string[]; + /** The plugin needs OPERATOR-supplied hosts, its egress list is not the whole story. */ + operatorEgress?: boolean; + settings?: Array<{ key: string; label: string; inputType: string; scope: string; required: boolean }>; + license: string | null; + icon: string | null; + requiredAddons?: string[]; + pluginDependencies?: PluginDep[]; + /** Display slice of the manifest's capabilities, drives the same chips as an installed row. */ + capabilities?: { + widget?: { slot?: string }; + tripPage?: { replaces?: string[] }; + /** Tools the plugin will publish on the MCP server once mcp:tools is granted. */ + mcpTools?: Array<{ name: string; title?: string; description: string }>; + /** Explore-pill categories it adds (#1781), checked again before anything draws them. */ + poiCategories?: unknown; + }; + } | null; +} + +/** 409 error-body shape from POST /activate when a dependency blocks activation. */ +export interface ActivateErr { + response?: { + status?: number; + data?: { + code?: string; + error?: string; + newPermissions?: string[]; + newEgress?: string[]; + addons?: string[]; + missing?: PluginDep[]; + versionMismatch?: VersionMismatch[]; + }; + }; +} + +/** The server's error envelope. Reading `code`, not the message text, is what lets the + * UI tell a rotated key (overridable) from a signature that doesn't verify (never). */ +export function errBody(e: unknown): { error?: string; code?: string } { + return (e as { response?: { data?: { error?: string; code?: string } } })?.response?.data ?? {}; +} + +/** The ONE signature refusal an admin may override, because a rotation has a benign + * explanation. SIGNATURE_INVALID / _MISSING / _INCOMPLETE mean the bytes are not what the + * author signed: those get an explanation and no override button at all. */ +const RETRUSTABLE = 'SIGNATURE_KEY_CHANGED'; +export const SIGNATURE_CODES = [RETRUSTABLE, 'SIGNATURE_INVALID', 'SIGNATURE_MISSING', 'SIGNATURE_INCOMPLETE']; + +/** + * What the signature dialog needs to talk about a plugin, deliberately NOT a PluginRow. + * + * A refusal can land on a plugin that is not installed (a fresh install from Discover, or a + * dependency being downloaded), and those have no row. Keying the dialog off PluginRow meant + * the lookup missed and the refusal silently degraded to a toast, on the very path where an + * admin most often meets these codes for the first time. Name + pinned fingerprint is all the + * dialog ever reads, and both a row and a registry entry can supply that. + */ +export type SigSubject = { id: string; name: string; keyFingerprint: string | null }; + +/** + * Is a recorded update block still describing the version on offer? + * + * Once the registry offers something NEWER than the version that was refused, the block + * describes an artifact nobody is being offered anymore, so it reads as stale and the + * admin can simply re-attempt (the next install re-verifies and either succeeds or + * re-blocks with fresh values). When the registry is unreachable we can't prove staleness, + * so the block stands: silently dropping the last thing we knew would be the worse failure. + */ +export function blockIsCurrent(p: PluginRow, latestVer?: string): boolean { + if (!p.updateBlock) return false; + // A block describes a refused REGISTRY update. Once a plugin is sideloaded or dev-linked + // it has left the registry trust model: the running code is whatever the admin supplied, + // and a block about an author signing key says nothing about it. The server clears the + // block on both paths; this makes it impossible to render a stale one regardless. + if (!isRegistrySourced(p.source_repo)) return false; + if (!latestVer || !p.updateBlock.version) return true; + return latestVer === p.updateBlock.version; +} + +export type T = (k: string, p?: Record) => string; +export type TypeFilter = 'all' | 'widget' | 'page' | 'integration' | 'trip-page'; +export type StatusFilter = 'all' | 'on' | 'off' | 'update' | 'err'; +export type SortKey = 'name' | 'recent' | 'updates' | 'downloads'; + +// Known permissions → human-readable i18n key; unknown ones render as raw code. +// Generated from the host's protocol/envelope.ts by +// server/scripts/gen-plugin-facts.ts - this used to be a hand-kept fourth copy. +export const PERM_KEYS = PLUGIN_PERMISSIONS; + +export const KNOWN_TYPES = ['widget', 'page', 'integration', 'trip-page']; + +export function isNewer(a: string, b: string): boolean { + const nums = (v: string) => + v + .split('-')[0] + .split('.') + .map((n) => Number.parseInt(n, 10) || 0); + const pa = nums(a), + pb = nums(b); + for (let i = 0; i < 3; i++) { + const x = pa[i] || 0, + y = pb[i] || 0; + if (x !== y) return x > y; + } + return !a.includes('-') && b.includes('-'); +} + +export function parseJson(raw: string | null | undefined, fallback: T): T { + try { + const v = JSON.parse(raw || '') as T; + return v ?? fallback; + } catch { + return fallback; + } +} + +export interface DepChip { + icon: ComponentType<{ size?: number; className?: string }>; + label: string; + blocked: boolean; + warn?: boolean; +} + +// A plugin's declared dependencies as chips: a required addon (amber when that +// addon is disabled), a plugin dependency (amber when missing / version-mismatched), +// or the TREK version itself (amber when this server has outgrown the plugin's range, +// which is the one blocker the admin cannot fix by flipping something else on). +export function deriveDeps(p: PluginRow, t: T): DepChip[] { + const out: DepChip[] = []; + const issues = p.dependencyIssues; + if (p.dependencyStatus === 'hostIncompatible') { + out.push({ + icon: AlertTriangle, + label: p.trekRange + ? t('admin.plugins.dep.trekIncompatible', { range: p.trekRange, host: p.hostVersion ?? '?' }) + : t('admin.plugins.dep.trekUnknown'), + blocked: true, + }); + } + const bypassed = bypassChip(p.trekRangeBypassed, t); // TREK_PLUGINS_IGNORE_TREK_RANGE: a warning, not a blocker + if (bypassed) out.push(bypassed); + for (const a of p.dependencies?.requiredAddons ?? []) { + out.push({ + icon: Blocks, + label: t('admin.plugins.cap.requiresAddon', { addon: a }), + blocked: !!issues?.disabledAddons.includes(a), + }); + } + for (const d of p.dependencies?.pluginDependencies ?? []) { + const blocked = !!( + issues?.missing.some((m) => m.id === d.id) || issues?.versionMismatch.some((m) => m.id === d.id) + ); + out.push({ icon: Puzzle, label: t('admin.plugins.cap.dependsOn', { id: d.id, version: d.version }), blocked }); + } + return out; +} + +/** + * What the Install button may do for a registry item. + * + * The server already decided compatibility: it owns the semver, and a second + * implementation here would eventually disagree with the install gate and offer a button + * that 400s. This only picks the wording. Note the middle case: when the newest release + * has outrun this TREK but an older one still fits, offer THAT version rather than a dead + * grey button. The plugin is perfectly usable, just not at its newest. + */ +export function installOffer( + item: RegistryItem, + t: T, + ignoreTrekRange = false +): { blocked: boolean; version?: string; label: string; title?: string; warn?: RangeWarning } { + if (item.compatible !== false) return { blocked: false, label: t('admin.plugins.install') }; + const title = item.trek + ? t('admin.plugins.dep.trekIncompatible', { range: item.trek, host: item.hostVersion ?? '?' }) + : t('admin.plugins.dep.trekUnknown'); + if (ignoreTrekRange) return bypassOffer(item, t, title); // TREK_PLUGINS_IGNORE_TREK_RANGE: "Install anyway" + if (item.latestCompatible) { + return { + blocked: false, + version: item.latestCompatible, + label: t('admin.plugins.installCompatible', { version: item.latestCompatible }), + title, + }; + } + return { blocked: true, label: t('admin.plugins.incompatible'), title }; +} + +/** + * Whether a plugin came from the REGISTRY (as opposed to a manual upload or a dev-link). + * + * This is the precedence rule for the trust badges, and it is load-bearing: `signed` + * derives from the pinned author key while sideloaded/dev-linked derive from source_repo, + * so the states are NOT mutually exclusive in the data. A sideloaded plugin genuinely has + * no pinned key, and a naive render would put "Unsigned" *and* "Sideloaded" side by side. + * The source badge wins, it already says something strictly stronger, and doubling up + * dilutes the amber into wallpaper, which is exactly what makes a warning worthless. + */ +export function isRegistrySourced(sourceRepo: string | null | undefined): boolean { + return !!sourceRepo && sourceRepo !== 'local:upload' && sourceRepo !== 'local:link'; +} + +// 1234 -> "1.2k": GitHub-style compact download counts for the browse cards. +// The M threshold sits at the k-rounding boundary so 999 950 is "1M", not "1000k". +export function formatCompactCount(n: number): string { + if (n >= 999_500) return `${Math.round(n / 100_000) / 10}M`; + if (n >= 1000) return `${Math.round(n / 100) / 10}k`; + return String(n); +} + +/** Client-side twin of the server's fingerprint: head…tail of the base64 payload. Display + * only, every equality check that matters happens server-side against the full key. */ +export function fingerprint(key: string | null): string | null { + if (!key) return null; + const payload = key + .trim() + .split(/\r?\n/) + .map((l) => l.trim()) + .filter((l) => l && !l.startsWith('untrusted comment')) + .pop(); + if (!payload) return null; + return payload.length <= 20 ? payload : `${payload.slice(0, 8)}…${payload.slice(-8)}`; +} + +export function statusLabel(s: StatusFilter, t: T): string { + return s === 'all' + ? t('admin.plugins.allStatuses') + : s === 'on' + ? t('admin.plugins.status.active') + : s === 'off' + ? t('admin.plugins.stateOff') + : s === 'update' + ? t('admin.plugins.filterUpdate') + : t('admin.plugins.status.error'); +} +export function sortLabel(s: SortKey, t: T): string { + return s === 'name' + ? t('admin.plugins.sortName') + : s === 'recent' + ? t('admin.plugins.sortRecent') + : s === 'downloads' + ? t('admin.plugins.sortDownloads') + : t('admin.plugins.sortUpdates'); +} + +// A held plugin (deliberate non-latest install) is deliberately NOT an update candidate: +// the admin just rolled it back, and the banner nagging them straight back would make +// the rollback fight the UI. The row carries its own "paused" marker + resume instead. +export function isUpdateAvailable(p: PluginRow, latest: Record): boolean { + return !!(p.version && !p.updateHold && latest[p.id] && isNewer(latest[p.id], p.version)); +} + +/** The installed list after the search, type and status filters, sorted. */ +export function filterInstalled( + plugins: PluginRow[], + { + q, + typeFilter, + statusFilter, + sort, + }: { q: string; typeFilter: TypeFilter; statusFilter: StatusFilter; sort: SortKey }, + latest: Record +): PluginRow[] { + const updateAvailable = (p: PluginRow) => isUpdateAvailable(p, latest); + const term = q.trim().toLowerCase(); + let rows = plugins.filter((p) => { + const matchesText = !term || `${p.name} ${p.description ?? ''}`.toLowerCase().includes(term); + const matchesType = typeFilter === 'all' || p.type === typeFilter; + const st = + statusFilter === 'all' + ? true + : statusFilter === 'on' + ? p.enabled === 1 && p.status !== 'error' + : statusFilter === 'off' + ? p.enabled === 0 + : statusFilter === 'update' + ? updateAvailable(p) + : p.status === 'error'; + return matchesText && matchesType && st; + }); + rows = [...rows].sort((a, b) => { + if (sort === 'updates') { + const ua = updateAvailable(a) ? 0 : 1, + ub = updateAvailable(b) ? 0 : 1; + if (ua !== ub) return ua - ub; + } + return a.name.localeCompare(b.name); + }); + return rows; +} + +/** The registry list after the search and type filters, sorted; null while it loads. */ +export function filterRegistry( + registry: RegistryItem[] | null, + { q, typeFilter, sort }: { q: string; typeFilter: TypeFilter; sort: SortKey } +): RegistryItem[] | null { + if (!registry) return null; + const term = q.trim().toLowerCase(); + let items = registry.filter((r) => { + const matchesText = !term || `${r.name} ${r.author} ${r.description}`.toLowerCase().includes(term); + const matchesType = typeFilter === 'all' || r.type === typeFilter; + return matchesText && matchesType; + }); + items = [...items].sort((a, b) => { + if (sort === 'downloads') return (b.downloadCount ?? 0) - (a.downloadCount ?? 0) || a.name.localeCompare(b.name); + if (sort === 'recent') + return ( + (Date.parse(b.reviewedAt ?? '') || 0) - (Date.parse(a.reviewedAt ?? '') || 0) || a.name.localeCompare(b.name) + ); + return a.name.localeCompare(b.name); + }); + return items; +} + +/** What the signature refusal dialog says, and the re-trust it may offer. */ +export function signatureBlockView(code: string, entry: RegistryItem | undefined) { + const canRetrust = code === RETRUSTABLE; + const newKey = entry?.authorPublicKey ?? null; + const version = entry?.latest ?? null; + // Only offer the override when we actually hold the new key + version to send: the + // request carries both, and the server compares the key exactly. + const retrust = canRetrust && newKey && version ? { newKey, version } : null; + + const bodyKey = + code === RETRUSTABLE + ? 'admin.plugins.sig.keyChangedBody' + : code === 'SIGNATURE_MISSING' + ? 'admin.plugins.sig.missingBody' + : code === 'SIGNATURE_INCOMPLETE' + ? 'admin.plugins.sig.incompleteBody' + : 'admin.plugins.sig.invalidBody'; + + return { canRetrust, newKey, retrust, bodyKey }; +} + +/** The dependencies a blocked activation still needs: the missing ones, then the outdated ones. */ +export function dependencyRows(data: { missing: PluginDep[]; versionMismatch: VersionMismatch[] }) { + const rows: Array<{ id: string; constraint: string; installed?: string }> = [ + ...data.missing.map((d) => ({ id: d.id, constraint: d.version })), + ...data.versionMismatch.map((d) => ({ id: d.id, constraint: d.wanted, installed: d.installed })), + ]; + return rows; +} diff --git a/client/src/components/Admin/plugins/usePluginDetail.test.ts b/client/src/components/Admin/plugins/usePluginDetail.test.ts new file mode 100644 index 000000000..96809ef6b --- /dev/null +++ b/client/src/components/Admin/plugins/usePluginDetail.test.ts @@ -0,0 +1,38 @@ +// FE-ADMIN-PLUGIN-DETAIL-001 to -003: the registry detail fetch behind both detail views. +import { renderHook, waitFor } from '@testing-library/react'; + +import { adminApi } from '../../../api/client'; +import { usePluginDetail } from './usePluginDetail'; + +afterEach(() => vi.restoreAllMocks()); + +describe('usePluginDetail', () => { + it('FE-ADMIN-PLUGIN-DETAIL-001: loads the detail for the id', async () => { + vi.spyOn(adminApi, 'pluginDetail').mockResolvedValue({ id: 'alpha', size: 2048 }); + const { result } = renderHook(() => usePluginDetail('alpha')); + expect(result.current.detail).toBeNull(); + await waitFor(() => expect(result.current.detail).toEqual({ id: 'alpha', size: 2048 })); + expect(result.current.failed).toBe(false); + expect(adminApi.pluginDetail).toHaveBeenCalledWith('alpha'); + }); + + it('FE-ADMIN-PLUGIN-DETAIL-002: a failed fetch is flagged and leaves no detail', async () => { + vi.spyOn(adminApi, 'pluginDetail').mockRejectedValue(new Error('offline')); + const { result } = renderHook(() => usePluginDetail('alpha')); + await waitFor(() => expect(result.current.failed).toBe(true)); + expect(result.current.detail).toBeNull(); + }); + + it('FE-ADMIN-PLUGIN-DETAIL-003: a response for an id the view moved off is dropped', async () => { + let resolveFirst!: (v: unknown) => void; + vi.spyOn(adminApi, 'pluginDetail').mockImplementation((id: string) => + id === 'alpha' ? new Promise((r) => (resolveFirst = r)) : Promise.resolve({ id }) + ); + const { result, rerender } = renderHook(({ id }) => usePluginDetail(id), { initialProps: { id: 'alpha' } }); + rerender({ id: 'beta' }); + await waitFor(() => expect(result.current.detail).toEqual({ id: 'beta' })); + resolveFirst({ id: 'alpha' }); + await Promise.resolve(); + expect(result.current.detail).toEqual({ id: 'beta' }); + }); +}); diff --git a/client/src/components/Admin/plugins/usePluginDetail.ts b/client/src/components/Admin/plugins/usePluginDetail.ts new file mode 100644 index 000000000..5b493df49 --- /dev/null +++ b/client/src/components/Admin/plugins/usePluginDetail.ts @@ -0,0 +1,31 @@ +import { useEffect, useState } from 'react'; + +import { adminApi } from '../../../api/client'; +import type { RegistryDetail } from './pluginModel'; + +/** + * The registry detail of one plugin, behind both shells' detail views (the desktop modal + * and the phone sheet). Fetched once per id; a response for an id the view has moved off + * is dropped. `D` lets a shell read the detail under its own stricter shape. + */ +export function usePluginDetail(id: string) { + const [detail, setDetail] = useState(null); + const [failed, setFailed] = useState(false); + + useEffect(() => { + let alive = true; + adminApi + .pluginDetail(id) + .then((d: D) => { + if (alive) setDetail(d); + }) + .catch(() => { + if (alive) setFailed(true); + }); + return () => { + alive = false; + }; + }, [id]); + + return { detail, failed }; +} diff --git a/client/src/components/Admin/plugins/usePluginsAdmin.test.ts b/client/src/components/Admin/plugins/usePluginsAdmin.test.ts new file mode 100644 index 000000000..ce22de7a5 --- /dev/null +++ b/client/src/components/Admin/plugins/usePluginsAdmin.test.ts @@ -0,0 +1,272 @@ +// FE-ADMIN-PLUGINS-HOOK-001 to -012: the plugin admin logic behind both admin shells. +import { act, renderHook, waitFor } from '@testing-library/react'; +import type { DragEvent } from 'react'; + +import { adminApi } from '../../../api/client'; +import type { PluginRow, RegistryItem } from './pluginModel'; +import { usePluginsAdmin } from './usePluginsAdmin'; + +const toast = vi.hoisted(() => ({ success: vi.fn(), error: vi.fn() })); +const loadPlugins = vi.hoisted(() => vi.fn(async () => {})); +vi.mock('../../shared/Toast', () => ({ useToast: () => toast })); +vi.mock('../../../i18n', () => ({ + useTranslation: () => ({ + t: (key: string, params?: Record) => (params ? `${key} ${JSON.stringify(params)}` : key), + }), +})); +vi.mock('../../../store/pluginStore', () => ({ usePluginStore: { getState: () => ({ loadPlugins }) } })); + +function row(over: Partial = {}): PluginRow { + return { + id: 'alpha', + name: 'Alpha', + description: null, + type: 'widget', + icon: null, + version: '1.0.0', + status: 'active', + enabled: 1, + last_error: null, + reviewed_at: null, + source_repo: 'org/alpha', + permissions: '[]', + capabilities: '{}', + ...over, + }; +} + +function entry(over: Partial = {}): RegistryItem { + return { + id: 'alpha', + name: 'Alpha', + author: 'someone', + description: 'does things', + repo: 'org/alpha', + type: 'widget', + latest: '1.1.0', + latestCompatible: '1.1.0', + minTrekVersion: null, + reviewedAt: null, + screenshotUrl: null, + ...over, + }; +} + +function serve(plugins: PluginRow[], registry: RegistryItem[] = []) { + vi.spyOn(adminApi, 'plugins').mockResolvedValue({ enabled: true, devLink: true, plugins }); + vi.spyOn(adminApi, 'pluginBrowse').mockResolvedValue(registry); +} + +/** A promise this test controls the settling of. */ +function deferred() { + let resolve!: (v: T) => void; + const promise = new Promise((r) => { + resolve = r; + }); + return { promise, resolve }; +} + +async function mounted(options?: Parameters[0]) { + const hook = renderHook(() => usePluginsAdmin(options)); + await waitFor(() => expect(hook.result.current.loading).toBe(false)); + return hook; +} + +function dragEvent(types: string[]): DragEvent { + return { dataTransfer: { types, files: [] }, preventDefault: vi.fn() } as unknown as DragEvent; +} + +beforeEach(() => { + toast.success.mockReset(); + toast.error.mockReset(); + loadPlugins.mockClear(); +}); +afterEach(() => vi.restoreAllMocks()); + +describe('usePluginsAdmin', () => { + it('FE-ADMIN-PLUGINS-HOOK-001: loads the plugins, syncs the app store and indexes the registry', async () => { + serve([row()], [entry()]); + const { result } = await mounted(); + expect(loadPlugins).toHaveBeenCalled(); + expect(result.current.runtimeOn).toBe(true); + expect(result.current.devLink).toBe(true); + expect(result.current.ready).toBe(true); + await waitFor(() => expect(result.current.latest).toEqual({ alpha: '1.1.0' })); + expect(result.current.regById.alpha?.name).toBe('Alpha'); + expect(result.current.updatable.map((p) => p.id)).toEqual(['alpha']); + }); + + it('FE-ADMIN-PLUGINS-HOOK-002: a failed load leaves the panel in its error state', async () => { + vi.spyOn(adminApi, 'plugins').mockRejectedValue(new Error('down')); + const { result } = await mounted(); + expect(result.current.error).toBe(true); + expect(result.current.ready).toBe(false); + }); + + it('FE-ADMIN-PLUGINS-HOOK-003: switching tabs snaps a sort the tab cannot offer back to name', async () => { + serve([row()]); + const { result } = await mounted(); + act(() => result.current.setSort('updates')); + act(() => result.current.setView('discover')); + expect(result.current.sort).toBe('name'); + act(() => result.current.setSort('downloads')); + act(() => result.current.setView('installed')); + expect(result.current.sort).toBe('name'); + }); + + it('FE-ADMIN-PLUGINS-HOOK-004: disabling a plugin deactivates it, says so and reloads the list', async () => { + serve([row()]); + vi.spyOn(adminApi, 'pluginDeactivate').mockResolvedValue({}); + const { result } = await mounted(); + await act(async () => { + result.current.toggle(result.current.plugins[0]!); + }); + await waitFor(() => expect(toast.success).toHaveBeenCalledWith('admin.plugins.deactivated')); + expect(adminApi.pluginDeactivate).toHaveBeenCalledWith('alpha'); + await waitFor(() => expect(adminApi.plugins).toHaveBeenCalledTimes(2)); + }); + + it('FE-ADMIN-PLUGINS-HOOK-005: an activation needing consent queues it, and approving activates with consent', async () => { + serve([row({ enabled: 0, signed: false })]); + vi.spyOn(adminApi, 'pluginActivate') + .mockRejectedValueOnce({ + response: { status: 409, data: { code: 'CONSENT_REQUIRED', newPermissions: ['db:own'], newEgress: [] } }, + }) + .mockResolvedValue({}); + const { result } = await mounted(); + await act(async () => { + result.current.toggle(result.current.plugins[0]!); + }); + await waitFor(() => expect(result.current.consentQueue).toHaveLength(1)); + expect(result.current.consentQueue[0]).toMatchObject({ version: '1.0.0', newPermissions: ['db:own'] }); + expect(result.current.consentUnsigned).toBe(true); + await act(async () => { + await result.current.approveConsent(); + }); + expect(adminApi.pluginActivate).toHaveBeenLastCalledWith('alpha', true); + expect(result.current.consentQueue).toHaveLength(0); + }); + + it('FE-ADMIN-PLUGINS-HOOK-006: deferring consent drops it from the queue and says the plugin stays off', async () => { + serve([row({ enabled: 0 })]); + vi.spyOn(adminApi, 'pluginActivate').mockRejectedValue({ + response: { status: 409, data: { code: 'CONSENT_REQUIRED', newPermissions: [], newEgress: ['x.example'] } }, + }); + const { result } = await mounted(); + await act(async () => { + result.current.toggle(result.current.plugins[0]!); + }); + await waitFor(() => expect(result.current.consentQueue).toHaveLength(1)); + act(() => result.current.deferConsent()); + expect(result.current.consentQueue).toHaveLength(0); + expect(toast.success).toHaveBeenCalledWith('admin.plugins.updateKeptOff'); + }); + + it('FE-ADMIN-PLUGINS-HOOK-007: a signature refusal opens the dialog, named from the registry for a new install', async () => { + serve([], [entry({ id: 'beta', name: 'Beta' })]); + vi.spyOn(adminApi, 'pluginInstall').mockRejectedValue({ + response: { data: { code: 'SIGNATURE_INVALID', error: 'bad signature' } }, + }); + const { result } = await mounted(); + await act(async () => { + result.current.openDiscover(); + }); + await waitFor(() => expect(result.current.regById.beta).toBeDefined()); + await act(async () => { + result.current.install('beta'); + }); + await waitFor(() => expect(result.current.signatureBlock).not.toBeNull()); + expect(result.current.signatureBlock).toEqual({ + subject: { id: 'beta', name: 'Beta', keyFingerprint: null }, + code: 'SIGNATURE_INVALID', + detail: 'bad signature', + }); + expect(toast.error).not.toHaveBeenCalled(); + }); + + it('FE-ADMIN-PLUGINS-HOOK-008: by default Update all runs the updates one after another', async () => { + serve([row({ id: 'a', name: 'A' }), row({ id: 'b', name: 'B' })], [entry({ id: 'a' }), entry({ id: 'b' })]); + const first = deferred(); + const update = vi + .spyOn(adminApi, 'pluginUpdate') + .mockReturnValueOnce(first.promise) + .mockResolvedValue({ version: '1.1.0', activated: true, newPermissions: [], newEgress: [] }); + const { result } = await mounted(); + await waitFor(() => expect(result.current.updatable).toHaveLength(2)); + let done!: Promise; + act(() => { + done = result.current.updateAll(); + }); + expect(update).toHaveBeenCalledTimes(1); + await act(async () => { + first.resolve({ version: '1.1.0', activated: true, newPermissions: [], newEgress: [] }); + await done; + }); + expect(update).toHaveBeenCalledTimes(2); + }); + + it('FE-ADMIN-PLUGINS-HOOK-009: with updateAllInParallel every update starts at once, without a version', async () => { + serve([row({ id: 'a', name: 'A' }), row({ id: 'b', name: 'B' })], [entry({ id: 'a' }), entry({ id: 'b' })]); + const update = vi.spyOn(adminApi, 'pluginUpdate').mockReturnValue(new Promise(() => {})); + const { result } = await mounted({ updateAllInParallel: true }); + await waitFor(() => expect(result.current.updatable).toHaveLength(2)); + act(() => { + void result.current.updateAll(); + }); + expect(update.mock.calls).toEqual([ + ['a', undefined], + ['b', undefined], + ]); + }); + + it('FE-ADMIN-PLUGINS-HOOK-010: the drop overlay arms on a file drag and ignores an unmatched leave', async () => { + serve([row()]); + const { result } = await mounted(); + act(() => result.current.onDragLeave()); + expect(result.current.dragActive).toBe(false); + act(() => result.current.onDragEnter(dragEvent(['text/plain']))); + expect(result.current.dragActive).toBe(false); + act(() => result.current.onDragEnter(dragEvent(['Files']))); + act(() => result.current.onDragEnter(dragEvent(['Files']))); + act(() => result.current.onDragLeave()); + expect(result.current.dragActive).toBe(true); + act(() => result.current.onDragLeave()); + expect(result.current.dragActive).toBe(false); + }); + + it('FE-ADMIN-PLUGINS-HOOK-011: picking an older version asks first, and confirming installs that version', async () => { + serve([row({ version: '2.0.0' })]); + vi.spyOn(adminApi, 'pluginDetail').mockResolvedValue({ versions: [] }); + const update = vi + .spyOn(adminApi, 'pluginUpdate') + .mockResolvedValue({ version: '1.0.0', activated: true, newPermissions: [], newEgress: [] }); + const { result } = await mounted(); + act(() => result.current.openVersionPicker(result.current.plugins[0]!)); + await waitFor(() => expect(result.current.versionPicker?.versions).toEqual([])); + act(() => result.current.pickVersion('1.0.0')); + expect(result.current.versionPicker).toBeNull(); + expect(result.current.confirmDowngrade).toMatchObject({ version: '1.0.0' }); + await act(async () => { + result.current.downgradeConfirmed(); + }); + expect(update).toHaveBeenCalledWith('alpha', '1.0.0'); + expect(result.current.confirmDowngrade).toBeNull(); + }); + + it('FE-ADMIN-PLUGINS-HOOK-012: uninstall goes through the confirm, and the row menu names its plugin', async () => { + serve([row()]); + vi.spyOn(adminApi, 'pluginUninstall').mockResolvedValue({}); + const { result } = await mounted(); + act(() => result.current.setMenu('row:alpha')); + expect(result.current.rowMenuPlugin?.id).toBe('alpha'); + act(() => result.current.askUninstall(result.current.plugins[0]!)); + expect(result.current.menu).toBeNull(); + expect(result.current.confirmUninstall?.id).toBe('alpha'); + await act(async () => { + await result.current.uninstallConfirmed(); + }); + expect(adminApi.pluginUninstall).toHaveBeenCalledWith('alpha', true); + expect(toast.success).toHaveBeenCalledWith('admin.plugins.uninstalled'); + expect(result.current.confirmUninstall).toBeNull(); + }); +}); diff --git a/client/src/components/Admin/plugins/usePluginsAdmin.ts b/client/src/components/Admin/plugins/usePluginsAdmin.ts new file mode 100644 index 000000000..98cb934eb --- /dev/null +++ b/client/src/components/Admin/plugins/usePluginsAdmin.ts @@ -0,0 +1,725 @@ +import { useEffect, useMemo, useRef, useState, type DragEvent } from 'react'; +import { adminApi } from '../../../api/client'; +import { useTranslation } from '../../../i18n'; +import { usePluginStore } from '../../../store/pluginStore'; +import { useToast } from '../../shared/Toast'; +import { useInstanceSettings } from '../useInstanceSettings'; +import { useRangeBypass, type RangeWarning, type TrekRangeBypass } from '../useRangeBypass'; +import { + SIGNATURE_CODES, + errBody, + filterInstalled, + filterRegistry, + isNewer, + isRegistrySourced, + isUpdateAvailable, + type ActivateErr, + type PluginDep, + type PluginRow, + type RegistryDetail, + type RegistryItem, + type SigSubject, + type SortKey, + type StatusFilter, + type TypeFilter, + type VersionInfo, + type VersionMismatch, +} from './pluginModel'; + +interface UsePluginsAdminOptions { + /** Start every pending update at once from "Update all" instead of one after another. */ + updateAllInParallel?: boolean; +} + +/** + * Admin > Plugins, the logic behind both shells (the desktop panel and the phone panel + * render their own markup over it): the installed list and the registry, the toolbar + * filters, sideload and dev-link, enable/disable with its dependency and consent gates, + * updates and version switches, signature refusals and re-trust, egress hosts, error + * logs and the instance settings form. + */ +export function usePluginsAdmin({ updateAllInParallel = false }: UsePluginsAdminOptions = {}) { + const { t } = useTranslation(); + const toast = useToast(); + const [runtimeOn, setRuntimeOn] = useState(false); + const [devLink, setDevLink] = useState(false); // dev-link enabled server-side (TREK_PLUGINS_DEV_LINK) + const [ignoreTrekRange, setIgnoreTrekRange] = useState(false); // TREK_PLUGINS_IGNORE_TREK_RANGE set server-side + const bypass = useRangeBypass(); // its warning dialog/sheet state, shared with the other shell + const [linkPath, setLinkPath] = useState(''); + const [plugins, setPlugins] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(false); + const [busy, setBusy] = useState(null); + const [view, setView] = useState<'installed' | 'discover'>('installed'); + const [registry, setRegistry] = useState(null); + const [latest, setLatest] = useState>({}); + // The registry entry per id: the re-trust dialog needs the NEW author key from it (in + // full: the server's equality check is exact), and the Discover/consent copy needs `signed`. + const [regById, setRegById] = useState>({}); + // Open when a signature check refused an install/update. Carries the code, so the dialog + // knows whether an override may even be offered. + const [signatureBlock, setSignatureBlock] = useState<{ + subject: SigSubject; + code: string; + detail: string | null; + } | null>(null); + const [retrusting, setRetrusting] = useState(false); + const [detailFor, setDetailFor] = useState(null); + const [errorsFor, setErrorsFor] = useState<{ + id: string; + rows: Array<{ ts: string; level: string; message: string }>; + } | null>(null); + const [egressFor, setEgressFor] = useState<{ id: string; supported: boolean; hosts: string[] } | null>(null); + // The admin-owned scope:'instance' settings form, shared logic with the other shell. + const settings = useInstanceSettings(); + const [egressDraft, setEgressDraft] = useState(''); + const [egressSaving, setEgressSaving] = useState(false); + const [egressError, setEgressError] = useState(''); + const [confirmUninstall, setConfirmUninstall] = useState(null); + // The version picker for an INSTALLED plugin ("Change version…"); versions land async + // from the registry detail endpoint, which computes each version's compat verdict. + const [versionPicker, setVersionPicker] = useState<{ + plugin: PluginRow; + versions: VersionInfo[] | null; + failed: boolean; + } | null>(null); + // A picked version OLDER than the installed one, held here until the admin consents + // to the data risk (the plugin's data dir stays; older code may not understand it). + const [confirmDowngrade, setConfirmDowngrade] = useState<{ plugin: PluginRow; version: string } | null>(null); + // A QUEUE, not one slot: "Update All" can produce several re-consent prompts, and + // each must be shown, not silently overwritten by the last one. + const [consentQueue, setConsentQueue] = useState< + Array<{ plugin: PluginRow; version: string; newPermissions: string[]; newEgress: string[] }> + >([]); + // Open when enabling a plugin is blocked by missing/outdated plugin dependencies. + const [depResolve, setDepResolve] = useState<{ + plugin: PluginRow; + missing: PluginDep[]; + versionMismatch: VersionMismatch[]; + } | null>(null); + const [menu, setMenu] = useState(null); + + // Toolbar state. + const [q, setQ] = useState(''); + const [typeFilter, setTypeFilter] = useState('all'); + const [statusFilter, setStatusFilter] = useState('all'); + const [sort, setSort] = useState('name'); + + // 'updates' only ranks installed plugins, 'downloads' only the registry, so snap + // the key back to name when switching tabs so the dropdown never carries a label + // for an option the active tab can't offer. + useEffect(() => { + if (view === 'discover' && sort === 'updates') setSort('name'); + else if (view === 'installed' && sort === 'downloads') setSort('name'); + }, [view, sort]); + + // Sideload upload: drag a plugin .zip onto the panel or use the toolbar button. + const [dragActive, setDragActive] = useState(false); + const uploadInputRef = useRef(null); + const dragDepth = useRef(0); + + // Index the registry once per fetch: the version map the update badges read, plus the + // whole entry (author key + signed flag) the trust badges and re-trust dialog need. + // The map holds latestCompatible, the newest version THIS TREK can install (computed + // server-side), not the absolute latest, so the banner never counts an update the + // update endpoint would refuse. + const indexRegistry = (items: RegistryItem[]) => { + const vers: Record = {}; + const byId: Record = {}; + items.forEach((i) => { + byId[i.id] = i; + if (i.latestCompatible) vers[i.id] = i.latestCompatible; + }); + setLatest(vers); + setRegById(byId); + }; + + const refresh = () => { + // Keep the app-wide active-plugin store in sync so widget/hero/tab consumers + // (e.g. the dashboard) reflect an activate/deactivate without a full reload (F5). + void usePluginStore.getState().loadPlugins(); + adminApi + .plugins() + .then((d: { enabled: boolean; devLink?: boolean; ignoreTrekRange?: boolean; plugins: PluginRow[] }) => { + setRuntimeOn(!!d.enabled); + setDevLink(!!d.devLink); + setIgnoreTrekRange(!!d.ignoreTrekRange); + setPlugins(d.plugins || []); + if ((d.plugins || []).length) { + adminApi + .pluginBrowse() + .then(indexRegistry) + .catch(() => {}); + } + }) + .catch(() => setError(true)) + .finally(() => setLoading(false)); + }; + useEffect(refresh, []); + + // A signature refusal is not an ordinary "action failed": it is the one class of error + // where WHICH failure it was decides what the admin may do about it. Route it to the + // dialog off the CODE, never off the message text. Returns true when handled. + // + // The subject is resolved from wherever it can be: the installed row, else the registry + // entry (a plugin being INSTALLED has no row yet), else the bare id. A signature code + // ALWAYS opens the dialog: falling back to a toast is what used to hide an invalid + // signature on a fresh install behind the same treatment as a network blip. + const routeSignatureError = (id: string, code?: string, detail?: string): boolean => { + if (!code || !SIGNATURE_CODES.includes(code)) return false; + const row = plugins.find((p) => p.id === id); + const subject: SigSubject = row + ? { id, name: row.name, keyFingerprint: row.keyFingerprint ?? null } + : // Not installed: no pinned key exists, so SIGNATURE_KEY_CHANGED cannot arise and the + // dialog shows the no-override explanation, which is exactly right. + { id, name: regById[id]?.name ?? id, keyFingerprint: null }; + setSignatureBlock({ subject, code, detail: detail ?? null }); + return true; + }; + + const act = async (id: string, fn: () => Promise, ok: string) => { + setBusy(id); + setMenu(null); + try { + await fn(); + toast.success(ok); + } catch (e) { + const { error, code } = errBody(e); + if (!routeSignatureError(id, code, error)) toast.error(error || t('admin.plugins.actionError')); + } finally { + setBusy(null); + refresh(); + } + }; + + const openDiscover = () => { + setView('discover'); + if (!registry) { + adminApi + .pluginBrowse() + .then((items: RegistryItem[]) => { + setRegistry(items); + indexRegistry(items); + }) + .catch(() => setRegistry([])); + } + }; + // The rescan/reload button rediscovers locally-installed plugins AND force-pulls + // the remote registry (bypassing the 30-min server cache + GitHub's CDN), so a + // just-published plugin shows up right away instead of up to ~35 min later. + const rescan = () => + act( + '__rescan', + async () => { + await adminApi.pluginRescan(); + const items: RegistryItem[] = await adminApi.pluginBrowse(true); + setRegistry(items); + indexRegistry(items); + }, + t('admin.plugins.rescanned') + ); + + // Sideload a plugin archive (installs INACTIVE: the admin still consents on activation). + const uploadPlugin = async (file: File) => { + setBusy('__upload'); + setMenu(null); + try { + const res = await adminApi.pluginUpload(file); + setView('installed'); + toast.success(t('admin.plugins.uploaded', { name: res.id })); + bypass.notice(res.id, res.trekRangeBypassed); + } catch (e) { + toast.error( + (e as { response?: { data?: { error?: string } } })?.response?.data?.error || t('admin.plugins.actionError') + ); + } finally { + setBusy(null); + refresh(); + } + }; + const pickUpload = () => uploadInputRef.current?.click(); + const onDragEnter = (e: DragEvent) => { + if (!runtimeOn || !Array.from(e.dataTransfer.types).includes('Files')) return; + e.preventDefault(); + dragDepth.current++; + setDragActive(true); + }; + // Mirrors onDragEnter: a leave that had no matching enter (a non-file drag) is ignored + // instead of driving the depth counter negative. + const onDragLeave = () => { + if (dragDepth.current === 0) return; + if (--dragDepth.current === 0) setDragActive(false); + }; + const onDrop = (e: DragEvent) => { + e.preventDefault(); + dragDepth.current = 0; + setDragActive(false); + if (!runtimeOn) return; + const f = e.dataTransfer.files?.[0]; + if (f) void uploadPlugin(f); + }; + const openEgress = (id: string) => { + setMenu(null); + setEgressDraft(''); + setEgressError(''); + adminApi + .pluginEgressHosts(id) + .then((d) => setEgressFor({ id, supported: d.supported, hosts: d.hosts })) + .catch(() => setEgressFor({ id, supported: false, hosts: [] })); + }; + + // Saving RE-SPAWNS the plugin: the child's egress guard is installed once at init and + // a second init is refused, so a live child's allow-list can never be widened in place. + const saveEgress = async (hosts: string[]) => { + if (!egressFor) return; + setEgressSaving(true); + setEgressError(''); + try { + const d = await adminApi.pluginSetEgressHosts(egressFor.id, hosts); + setEgressFor({ ...egressFor, hosts: d.hosts }); + setEgressDraft(''); + } catch (e) { + const err = e as { response?: { data?: { error?: string } } }; + setEgressError(err.response?.data?.error || t('common.error')); + } finally { + setEgressSaving(false); + } + }; + + const openInstanceSettings = (p: { id: string; status: string }) => { + setMenu(null); + settings.open(p.id, p.status === 'active'); + }; + + const openErrors = (id: string) => { + setMenu(null); + adminApi + .pluginErrors(id) + .then((d: { errors: Array<{ ts: string; level: string; message: string }> }) => + setErrorsFor({ id, rows: d.errors }) + ) + .catch(() => setErrorsFor({ id, rows: [] })); + }; + + const updateAvailable = (p: PluginRow) => isUpdateAvailable(p, latest); + const resumeUpdates = (p: PluginRow) => + act(p.id, () => adminApi.pluginResumeUpdates(p.id), t('admin.plugins.updatesResumed')); + // A newer version exists but this TREK can't install it (and it isn't the one on offer): + // said passively on the row, so the admin learns a TREK upgrade unlocks it instead of + // wondering why no update shows. Null when the registry gives no range to point at. + const newerIncompatible = (p: PluginRow): { version: string; range: string } | null => { + const reg = regById[p.id]; + if (!reg?.latest || !p.version || !isNewer(reg.latest, p.version)) return null; + if (latest[p.id] === reg.latest) return null; + const range = reg.trek ?? (reg.minTrekVersion ? `>=${reg.minTrekVersion}` : null); + return range ? { version: reg.latest, range } : null; + }; + // `warn` is the pre-install confirm for an entry the registry already flagged as + // incompatible; an artifact whose OWN manifest turns out to be out of range (the index + // was only a pre-download filter) is caught by the marker on the response instead. + const install = (id: string, version?: string, warn?: RangeWarning) => + bypass.guard(warn, () => + act( + id, + () => + adminApi.pluginInstall(id, version ? { version } : undefined).then((r) => { + if (!warn) bypass.notice(id, r?.trekRangeBypassed); + }), + t('admin.plugins.installed') + ) + ); + const restart = (id: string) => + act( + id, + async () => { + await adminApi.pluginDeactivate(id); + await adminApi.pluginActivate(id); + }, + t('admin.plugins.restarted') + ); + // Dev-link: register a plugin from a local built directory (dev only). Reuses the + // same busy/toast/refresh loop as uploadPlugin; the server gates it. + const linkLocal = async () => { + const p = linkPath.trim(); + if (!p) return; + setBusy('__link'); + setMenu(null); + try { + const res = await adminApi.pluginLink(p); + setView('installed'); + setLinkPath(''); + toast.success(t('admin.plugins.devLinkLinked', { id: res.id })); + bypass.notice(res.id, res.trekRangeBypassed); + } catch (e) { + toast.error( + (e as { response?: { data?: { error?: string } } })?.response?.data?.error || t('admin.plugins.actionError') + ); + } finally { + setBusy(null); + refresh(); + } + }; + const installedIds = new Set(plugins.map((p) => p.id)); + + // Installed-but-disabled direct deps that enabling `p` will auto-enable first. + const autoEnabledDeps = (p: PluginRow) => + (p.dependencies?.pluginDependencies ?? []) + .map((d) => plugins.find((x) => x.id === d.id)) + .filter((x): x is PluginRow => !!x && x.enabled === 0) + .map((x) => x.name); + + // Shared handling for a failed activation: route each 409 code to the right fix + // (consent dialog, download-dependency dialog, or a clear toast). + const onActivateError = (p: PluginRow, e: ActivateErr) => { + const d = e?.response?.data; + if (e?.response?.status === 409 && d?.code === 'CONSENT_REQUIRED') { + setConsentQueue((qq) => [ + ...qq, + { + plugin: p, + version: latest[p.id] ?? p.version ?? '', + newPermissions: d.newPermissions ?? [], + newEgress: d.newEgress ?? [], + }, + ]); + } else if (e?.response?.status === 409 && d?.code === 'ADDON_DISABLED') { + toast.error( + t('admin.plugins.dep.addonDisabledToast', { + count: (d.addons ?? []).length, + addons: (d.addons ?? []).join(', '), + }) + ); + } else if (e?.response?.status === 409 && d?.code === 'DEPENDENCY_MISSING') { + setDepResolve({ plugin: p, missing: d.missing ?? [], versionMismatch: d.versionMismatch ?? [] }); + } else { + // DEPENDENCY_CYCLE and everything else surface their server message. + toast.error(d?.error || t('admin.plugins.actionError')); + } + }; + + const attemptActivate = (p: PluginRow) => { + const cascaded = autoEnabledDeps(p); + return adminApi + .pluginActivate(p.id) + .then(() => { + toast.success(t('admin.plugins.activated')); + if (cascaded.length) + toast.success(t('admin.plugins.dep.autoEnabled', { count: cascaded.length, plugins: cascaded.join(', ') })); + setDepResolve(null); + }) + .catch((e: ActivateErr) => onActivateError(p, e)); + }; + + // Enable/disable a plugin. Re-enabling one whose update widened its permissions + // must NOT grant them silently (409 CONSENT_REQUIRED → consent dialog); a disabled + // required addon or a missing plugin dependency (409 ADDON_DISABLED / + // DEPENDENCY_MISSING) routes to the right remedy. + const toggle = (p: PluginRow) => { + if (busy === p.id) return; + if (p.enabled === 1) { + void act(p.id, () => adminApi.pluginDeactivate(p.id), t('admin.plugins.deactivated')); + return; + } + setBusy(p.id); + setMenu(null); + attemptActivate(p).finally(() => { + setBusy(null); + refresh(); + }); + }; + + // Download a missing/outdated plugin dependency (latest compatible for its range, + // transitively), then retry enabling the plugin that needed it. + const resolveDependency = (parent: PluginRow, depId: string, constraint?: string) => { + if (busy === parent.id) return; + setBusy(parent.id); + adminApi + .pluginInstall(depId, { constraint, withDependencies: true }) + .then((r: { installed?: string[]; requiredAddons?: string[]; trekRangeBypassed?: TrekRangeBypass | null }) => { + toast.success(t('admin.plugins.dep.downloaded', { id: depId })); + if (r?.requiredAddons?.length) + toast.error( + t('admin.plugins.dep.addonDisabledToast', { + count: r.requiredAddons.length, + addons: r.requiredAddons.join(', '), + }) + ); + bypass.notice(depId, r?.trekRangeBypassed); + return attemptActivate(parent); + }) + // The DEPENDENCY is what's being downloaded, so a signature refusal here is about the + // dependency's author, not the parent's, so route it under depId before falling through + // to the activation-error handling, which knows nothing about signature codes. + .catch((e: ActivateErr) => { + const { error, code } = errBody(e); + if (!routeSignatureError(depId, code, error)) onActivateError(parent, e); + }) + .finally(() => { + setBusy(null); + refresh(); + }); + }; + + // `version` pins the exact version to install (rollback / explicit switch); omitted, + // the server resolves the newest TREK-compatible version itself. + const runUpdate = (p: PluginRow, version?: string) => { + setBusy(p.id); + setMenu(null); + return adminApi + .pluginUpdate(p.id, version) + .then( + (r: { + version: string; + activated: boolean; + newPermissions: string[]; + newEgress: string[]; + trekRangeBypassed?: TrekRangeBypass | null; + }) => { + if (r.activated || (r.newPermissions.length === 0 && r.newEgress.length === 0)) + toast.success(t('admin.plugins.updated')); + else + setConsentQueue((qq) => [ + ...qq, + { plugin: p, version: r.version, newPermissions: r.newPermissions, newEgress: r.newEgress }, + ]); + bypass.notice(p.name, r.trekRangeBypassed); + } + ) + .catch((e) => { + const { error, code } = errBody(e); + if (!routeSignatureError(p.id, code, error)) toast.error(error || t('admin.plugins.actionError')); + }) + .finally(() => { + setBusy(null); + refresh(); + }); + }; + + // Confirm a key rotation: re-pin the new key AND update, in one server call. There is no + // follow-up /update: a re-pin that waited for a second call would leave the plugin + // pinned to a key no install had ever been verified against if that call never came. + const confirmRetrust = (id: string, version: string, publicKey: string) => { + setRetrusting(true); + adminApi + .pluginRetrust(id, version, publicKey) + .then((r: { version: string; activated: boolean; newPermissions: string[]; newEgress: string[] }) => { + setSignatureBlock(null); + // A re-trusted update widening permissions still needs consent: re-trusting a + // signing key says nothing about what the new code is allowed to do. Re-trust only + // ever fires for an INSTALLED plugin, so the row is there to consent against. + const p = plugins.find((x) => x.id === id); + if (p && !r.activated && (r.newPermissions.length > 0 || r.newEgress.length > 0)) { + setConsentQueue((qq) => [ + ...qq, + { plugin: p, version: r.version, newPermissions: r.newPermissions, newEgress: r.newEgress }, + ]); + } else toast.success(t('admin.plugins.retrusted')); + }) + .catch((e) => toast.error(errBody(e).error || t('admin.plugins.actionError'))) + .finally(() => { + setRetrusting(false); + refresh(); + }); + }; + + // "Change version…" on an installed row: fetch the per-version list (with the server's + // compat verdicts) and open the picker. The fetch is keyed to the plugin so a stale + // response can't populate a picker that was reopened for another row. + const openVersionPicker = (p: PluginRow) => { + setMenu(null); + setVersionPicker({ plugin: p, versions: null, failed: false }); + adminApi + .pluginDetail(p.id) + .then((d: RegistryDetail) => + setVersionPicker((cur) => (cur && cur.plugin.id === p.id ? { ...cur, versions: d.versions ?? [] } : cur)) + ) + .catch(() => setVersionPicker((cur) => (cur && cur.plugin.id === p.id ? { ...cur, failed: true } : cur))); + }; + + // Switching DOWN keeps the plugin's data dir, which the older code may not understand, so + // that asks for explicit consent first. Any other switch is the ordinary update path. + const pickVersion = (version: string) => { + if (!versionPicker) return; + const p = versionPicker.plugin; + setVersionPicker(null); + if (p.version && isNewer(p.version, version)) setConfirmDowngrade({ plugin: p, version }); + else void runUpdate(p, version); + }; + + const updatable = useMemo(() => plugins.filter((p) => isUpdateAvailable(p, latest)), [plugins, latest]); + + // One after another: busy is a single slot, so parallel updates would leave the + // other rows clickable mid-install and refresh once per plugin. The phone still + // starts them all at once (updateAllInParallel). + const updateAll = async () => { + if (updateAllInParallel) { + // p => runUpdate(p), NOT forEach(runUpdate): forEach's index would land in + // runUpdate's `version` parameter and pin the update to a number. + updatable.forEach((p) => void runUpdate(p)); + return; + } + for (const p of updatable) await runUpdate(p); + }; + + // Installed list after search / type / status filters + sort. + const shownInstalled = useMemo( + () => filterInstalled(plugins, { q, typeFilter, statusFilter, sort }, latest), + [plugins, q, typeFilter, statusFilter, sort, latest] + ); + + // Registry list after search / type filter. + const shownRegistry = useMemo( + () => filterRegistry(registry, { q, typeFilter, sort }), + [registry, q, typeFilter, sort] + ); + + const anyFilter = q.trim() !== '' || typeFilter !== 'all' || statusFilter !== 'all'; + const ready = runtimeOn && !loading && !error; + // A question opened from the detail view (the range warning, a signature refusal, the + // consent gate) sits on top of it; Escape and the backdrop leave the detail alone meanwhile. + const detailBlocked = !!bypass.copy || !!signatureBlock || consentQueue.length > 0; + + // The plugin whose row action sheet is open on the phone (menu === `row:${id}`). + const rowMenuPlugin = menu?.startsWith('row:') ? (plugins.find((p) => `row:${p.id}` === menu) ?? null) : null; + + /** Opens the signature dialog on the update a row's block describes. */ + const reviewBlock = (p: PluginRow) => + setSignatureBlock({ + subject: { id: p.id, name: p.name, keyFingerprint: p.keyFingerprint ?? null }, + code: p.updateBlock!.code, + detail: p.updateBlock!.detail, + }); + const askUninstall = (p: PluginRow) => { + setMenu(null); + setConfirmUninstall(p); + }; + const uninstallConfirmed = async () => { + const p = confirmUninstall!; + setConfirmUninstall(null); + await act(p.id, () => adminApi.pluginUninstall(p.id, true), t('admin.plugins.uninstalled')); + }; + const downgradeConfirmed = () => { + const d = confirmDowngrade!; + setConfirmDowngrade(null); + void runUpdate(d.plugin, d.version); + }; + const retrustBlocked = (version: string, publicKey: string) => { + if (signatureBlock) confirmRetrust(signatureBlock.subject.id, version, publicKey); + }; + + // Prefer the REGISTRY's flag: consent is about the code being installed, not the + // code running now. But fall back to the installed row's, which the server always + // sends. Reading only the registry meant an unreachable registry left `regById` + // empty and the warning silently vanished at the exact moment an admin was widening + // what unsigned code may do. + const consent = consentQueue[0]; + const consentUnsigned = + !!consent && + isRegistrySourced(consent.plugin.source_repo) && + (regById[consent.plugin.id]?.signed ?? consent.plugin.signed) === false; + const approveConsent = async () => { + const c = consentQueue[0]; + setConsentQueue((qq) => qq.slice(1)); + // consent:true is the ONLY path that may widen a plugin's granted rights. + await act(c.plugin.id, () => adminApi.pluginActivate(c.plugin.id, true), t('admin.plugins.updated')); + }; + const deferConsent = () => { + setConsentQueue((qq) => qq.slice(1)); + toast.success(t('admin.plugins.updateKeptOff')); + }; + const downloadDependency = (depId: string, constraint?: string) => { + if (depResolve) resolveDependency(depResolve.plugin, depId, constraint); + }; + + return { + bypass, + settings, + runtimeOn, + devLink, + ignoreTrekRange, + linkPath, + setLinkPath, + plugins, + loading, + error, + busy, + view, + setView, + registry, + latest, + regById, + signatureBlock, + setSignatureBlock, + retrusting, + detailFor, + setDetailFor, + errorsFor, + setErrorsFor, + egressFor, + setEgressFor, + egressDraft, + setEgressDraft, + egressSaving, + egressError, + confirmUninstall, + setConfirmUninstall, + versionPicker, + setVersionPicker, + confirmDowngrade, + setConfirmDowngrade, + consentQueue, + depResolve, + setDepResolve, + menu, + setMenu, + q, + setQ, + typeFilter, + setTypeFilter, + statusFilter, + setStatusFilter, + sort, + setSort, + dragActive, + uploadInputRef, + openDiscover, + rescan, + uploadPlugin, + pickUpload, + onDragEnter, + onDragLeave, + onDrop, + openEgress, + saveEgress, + openInstanceSettings, + openErrors, + updateAvailable, + resumeUpdates, + newerIncompatible, + install, + restart, + linkLocal, + installedIds, + toggle, + runUpdate, + openVersionPicker, + pickVersion, + updatable, + updateAll, + shownInstalled, + shownRegistry, + anyFilter, + ready, + detailBlocked, + rowMenuPlugin, + reviewBlock, + askUninstall, + uninstallConfirmed, + downgradeConfirmed, + retrustBlocked, + consentUnsigned, + approveConsent, + deferConsent, + downloadDependency, + }; +} diff --git a/client/src/mobile/screens/admin/MAdminPluginsPanel.tsx b/client/src/mobile/screens/admin/MAdminPluginsPanel.tsx index 4a3c3c304..cd7b4f03d 100644 --- a/client/src/mobile/screens/admin/MAdminPluginsPanel.tsx +++ b/client/src/mobile/screens/admin/MAdminPluginsPanel.tsx @@ -1,5 +1,4 @@ -import { useEffect, useMemo, useRef, useState, type DragEvent } from 'react' -import { PLUGIN_PERMISSIONS } from '@trek/shared' +import { useState } from 'react' import { Blocks, AlertTriangle, PackageOpen, RefreshCw, Trash2, Download, Bug, X, ShieldCheck, UploadCloud, ArrowUpCircle, Github, ExternalLink, ChevronDown, Check, Lock, Search, Link2, KeyRound, ShieldAlert, @@ -7,200 +6,39 @@ import { Globe, Info, History, PauseCircle, Puzzle, Bot, } from 'lucide-react' import PluginIcon from '../../../components/shared/PluginIcon' -import { adminApi } from '../../../api/client' -import { useInstanceSettings } from '../../../components/Admin/useInstanceSettings' -import { bypassChip, bypassOffer, useRangeBypass, type RangeWarning, type TrekRangeBypass } from '../../../components/Admin/useRangeBypass' +import { type RangeWarning } from '../../../components/Admin/useRangeBypass' import { deriveCaps } from '../../../components/Admin/pluginCaps' import PluginPoiCategoryList from '../../../components/Admin/PluginPoiCategoryList' -import { usePluginStore } from '../../../store/pluginStore' import { useTranslation } from '../../../i18n' -import { useToast } from '../../../components/shared/Toast' import MToggle from '../../components/MToggle' import MSheet from '../../components/MSheet' import MSegmented from '../../components/MSegmented' import MConfirmSheet from '../settings/MConfirmSheet' import { MAdminButton, MAdminSheetFrame } from './MAdminUi' +import { + KNOWN_TYPES, PERM_KEYS, blockIsCurrent, dependencyRows, deriveDeps, fingerprint, formatCompactCount, + installOffer, isRegistrySourced, parseJson, signatureBlockView, sortLabel, statusLabel, + type PluginDep, type PluginRow, type RegistryDetail, type RegistryItem, type SigSubject, type SortKey, + type StatusFilter, type T, type TypeFilter, type VersionInfo, type VersionMismatch, +} from '../../../components/Admin/plugins/pluginModel' +import { usePluginDetail } from '../../../components/Admin/plugins/usePluginDetail' +import { usePluginsAdmin } from '../../../components/Admin/plugins/usePluginsAdmin' + +type DetailManifest = NonNullable +/** The detail as the phone reads it: its sheet takes the manifest's lists as always present. */ +type MRegistryDetail = Omit & { + manifest: (DetailManifest & Required>) | null +} /** * Admin → Plugins, mobile skin. A drop-in replacement for the desktop - * AdminPluginsPanel: identical logic (segmented Installed/Discover switch, + * AdminPluginsPanel: the same logic from usePluginsAdmin (segmented Installed/Discover switch, * search + type/status/sort filters, updates bar, installed rows with capability * chips, a registry list, an enriched detail sheet and the update re-consent * gate) with the mobile design system as the presentation layer. Every dialog is * an MSheet; every boolean is an MToggle. */ -interface PluginDep { id: string; version: string } -interface VersionMismatch { id: string; wanted: string; installed: string } -type DependencyStatus = 'ok' | 'addonDisabled' | 'missingPlugin' | 'hostIncompatible' -interface PluginDependencies { requiredAddons: string[]; pluginDependencies: PluginDep[] } -interface DependencyIssues { disabledAddons: string[]; missing: PluginDep[]; versionMismatch: VersionMismatch[] } - -interface PluginRow { - id: string - name: string - description: string | null - type: string - icon: string | null - version: string | null - status: string - enabled: number - last_error: string | null - reviewed_at: string | null - source_repo: string | null - permissions: string - capabilities: string - /** The plugin needs OPERATOR-supplied egress hosts (a self-hosted target). */ - operatorEgress?: boolean - /** How many hosts the admin has added — 0 means the plugin can't reach anything yet. */ - egressHostCount?: number - /** How many `scope:'instance'` settings fields the plugin declares — gates the - * "Instance settings" sheet action without a per-plugin fetch. */ - instanceSettingsCount?: number - /** How many `scope:'instance'` actions the plugin declares — a plugin with actions - * but no settings fields still needs the sheet action to run them. */ - instanceActionsCount?: number - dependencies?: PluginDependencies - dependencyStatus?: DependencyStatus - dependencyIssues?: DependencyIssues - /** The TREK versions the plugin says it supports; null if it never declared any. */ - trekRange?: string | null - /** The TREK this server is running — the server does the semver, the client just shows it. */ - hostVersion?: string - /** Non-null while the plugin runs outside its declared range on the operator's say-so. */ - trekRangeBypassed?: TrekRangeBypass | null - /** The author's signature was verified and their key pinned at install. False means the - * bytes matched the registry's sha256 and nothing more — one fewer guarantee. */ - signed?: boolean - /** Short form of the pinned key, for eyeballing against what the author reads out. */ - keyFingerprint?: string | null - /** Why an update was refused, if one was. `version` is the version that was refused. */ - updateBlock?: { code: string; detail: string | null; version: string | null } | null - /** A deliberate non-latest install paused updates: out of the banner/Update all until resumed. */ - updateHold?: boolean -} -interface RegistryItem { - id: string - name: string - author: string - description: string - repo: string - homepage?: string | null - type: string - /** Lucide icon name from the registry entry; absent → Blocks. */ - icon?: string | null - latest: string | null - minTrekVersion: string | null - /** The latest version's declared TREK range; null on a legacy registry entry. */ - trek?: string | null - hostVersion?: string - /** Whether the LATEST version can be installed on this TREK. Computed server-side. */ - compatible?: boolean - /** Newest installable version — the latest, an older fallback, or null if none fits. */ - latestCompatible?: string | null - reviewedAt: string | null - downloadCount?: number | null - screenshotUrl: string | null - requiredAddons?: string[] - pluginDependencies?: PluginDep[] - /** The latest version ships an author signature and the entry declares a key. */ - signed?: boolean - /** The full key — public, and carried in full because re-trust compares it exactly. */ - authorPublicKey?: string | null -} -/** One published version, with its SERVER-computed compat verdict (the client has no semver). */ -interface VersionInfo { - version: string - publishedAt: string | null - size: number | null - signed: boolean - /** The declared TREK requirement, or null when the entry carries no bounds at all. */ - trek: string | null - compatible: boolean -} -interface RegistryDetail extends RegistryItem { - size: number | null - publishedAt: string | null - /** Every published version, newest first — the version picker's data. */ - versions?: VersionInfo[] - manifest: { - permissions: string[] - egress: string[] - /** The plugin needs OPERATOR-supplied hosts — its egress list is not the whole story. */ - operatorEgress?: boolean - settings: Array<{ key: string; label: string; inputType: string; scope: string; required: boolean }> - license: string | null - icon: string | null - requiredAddons?: string[] - pluginDependencies?: PluginDep[] - /** Display slice of the manifest's capabilities — drives the same chips as an installed row. */ - capabilities?: { - widget?: { slot?: string } - tripPage?: { replaces?: string[] } - /** Tools the plugin will publish on the MCP server once mcp:tools is granted. */ - mcpTools?: Array<{ name: string; title?: string; description: string }> - /** Explore-pill categories it adds (#1781), checked again before anything draws them. */ - poiCategories?: unknown - } - } | null -} - -/** 409 error-body shape from POST /activate when a dependency blocks activation. */ -interface ActivateErr { - response?: { - status?: number - data?: { code?: string; error?: string; newPermissions?: string[]; newEgress?: string[]; addons?: string[]; missing?: PluginDep[]; versionMismatch?: VersionMismatch[] } - } -} - -/** The server's error envelope. Reading `code` — not the message text — is what lets the - * UI tell a rotated key (overridable) from a signature that doesn't verify (never). */ -function errBody(e: unknown): { error?: string; code?: string } { - return (e as { response?: { data?: { error?: string; code?: string } } })?.response?.data ?? {} -} - -/** The ONE signature refusal an admin may override, because a rotation has a benign - * explanation. SIGNATURE_INVALID / _MISSING / _INCOMPLETE mean the bytes are not what the - * author signed — those get an explanation and no override button at all. */ -const RETRUSTABLE = 'SIGNATURE_KEY_CHANGED' -const SIGNATURE_CODES = [RETRUSTABLE, 'SIGNATURE_INVALID', 'SIGNATURE_MISSING', 'SIGNATURE_INCOMPLETE'] - -/** - * What the signature dialog needs to talk about a plugin — deliberately NOT a PluginRow. - * - * A refusal can land on a plugin that is not installed (a fresh install from Discover, or a - * dependency being downloaded), and those have no row. Keying the dialog off PluginRow meant - * the lookup missed and the refusal silently degraded to a toast — on the very path where an - * admin most often meets these codes for the first time. Name + pinned fingerprint is all the - * dialog ever reads, and both a row and a registry entry can supply that. - */ -type SigSubject = { id: string; name: string; keyFingerprint: string | null } - -/** - * Is a recorded update block still describing the version on offer? - * - * Once the registry offers something NEWER than the version that was refused, the block - * describes an artifact nobody is being offered anymore — so it reads as stale and the - * admin can simply re-attempt (the next install re-verifies and either succeeds or - * re-blocks with fresh values). When the registry is unreachable we can't prove staleness, - * so the block stands: silently dropping the last thing we knew would be the worse failure. - */ -function blockIsCurrent(p: PluginRow, latestVer?: string): boolean { - if (!p.updateBlock) return false - // A block describes a refused REGISTRY update. Once a plugin is sideloaded or dev-linked - // it has left the registry trust model — the running code is whatever the admin supplied, - // and a block about an author signing key says nothing about it. The server clears the - // block on both paths; this makes it impossible to render a stale one regardless. - if (!isRegistrySourced(p.source_repo)) return false - if (!latestVer || !p.updateBlock.version) return true - return latestVer === p.updateBlock.version -} - -type T = (k: string, p?: Record) => string -type TypeFilter = 'all' | 'widget' | 'page' | 'integration' | 'trip-page' -type StatusFilter = 'all' | 'on' | 'off' | 'update' | 'err' -type SortKey = 'name' | 'recent' | 'updates' | 'downloads' - // Runtime health → dot colour on the icon tile (mobile status tokens). const HEALTH: Record = { active: 'bg-[color:var(--m-st-confirmed)]', @@ -211,78 +49,6 @@ const HEALTH: Record = { incompatible: 'bg-[color:var(--m-st-pending)]', } -// Known permissions → human-readable i18n key; unknown ones render as raw code. -// Generated from the host's protocol/envelope.ts by -// server/scripts/gen-plugin-facts.ts - this used to be a hand-kept fourth copy. -const PERM_KEYS = PLUGIN_PERMISSIONS - -const KNOWN_TYPES = ['widget', 'page', 'integration', 'trip-page'] - -function isNewer(a: string, b: string): boolean { - const nums = (v: string) => v.split('-')[0].split('.').map(n => Number.parseInt(n, 10) || 0) - const pa = nums(a), pb = nums(b) - for (let i = 0; i < 3; i++) { - const x = pa[i] || 0, y = pb[i] || 0 - if (x !== y) return x > y - } - return !a.includes('-') && b.includes('-') -} - -function parseJson(raw: string | null | undefined, fallback: T): T { - try { const v = JSON.parse(raw || '') as T; return v ?? fallback } catch { return fallback } -} - -interface DepChip { icon: React.ComponentType<{ size?: number; className?: string }>; label: string; blocked: boolean; warn?: boolean } - -// A plugin's declared dependencies as chips — a required addon (amber when that -// addon is disabled), a plugin dependency (amber when missing / version-mismatched), -// or the TREK version itself (amber when this server has outgrown the plugin's range, -// which is the one blocker the admin cannot fix by flipping something else on). -function deriveDeps(p: PluginRow, t: T): DepChip[] { - const out: DepChip[] = [] - const issues = p.dependencyIssues - if (p.dependencyStatus === 'hostIncompatible') { - out.push({ - icon: AlertTriangle, - label: p.trekRange - ? t('admin.plugins.dep.trekIncompatible', { range: p.trekRange, host: p.hostVersion ?? '?' }) - : t('admin.plugins.dep.trekUnknown'), - blocked: true, - }) - } - const bypassed = bypassChip(p.trekRangeBypassed, t) // TREK_PLUGINS_IGNORE_TREK_RANGE: a warning, not a blocker - if (bypassed) out.push(bypassed) - for (const a of p.dependencies?.requiredAddons ?? []) { - out.push({ icon: Blocks, label: t('admin.plugins.cap.requiresAddon', { addon: a }), blocked: !!issues?.disabledAddons.includes(a) }) - } - for (const d of p.dependencies?.pluginDependencies ?? []) { - const blocked = !!(issues?.missing.some(m => m.id === d.id) || issues?.versionMismatch.some(m => m.id === d.id)) - out.push({ icon: Puzzle, label: t('admin.plugins.cap.dependsOn', { id: d.id, version: d.version }), blocked }) - } - return out -} - -/** - * What the Install button may do for a registry item. - * - * The server already decided compatibility — it owns the semver, and a second - * implementation here would eventually disagree with the install gate and offer a button - * that 400s. This only picks the wording. Note the middle case: when the newest release - * has outrun this TREK but an older one still fits, offer THAT version rather than a dead - * grey button. The plugin is perfectly usable, just not at its newest. - */ -function installOffer(item: RegistryItem, t: T, ignoreTrekRange = false): { blocked: boolean; version?: string; label: string; title?: string; warn?: RangeWarning } { - if (item.compatible !== false) return { blocked: false, label: t('admin.plugins.install') } - const title = item.trek - ? t('admin.plugins.dep.trekIncompatible', { range: item.trek, host: item.hostVersion ?? '?' }) - : t('admin.plugins.dep.trekUnknown') - if (ignoreTrekRange) return bypassOffer(item, t, title) // TREK_PLUGINS_IGNORE_TREK_RANGE: "Install anyway" - if (item.latestCompatible) { - return { blocked: false, version: item.latestCompatible, label: t('admin.plugins.installCompatible', { version: item.latestCompatible }), title } - } - return { blocked: true, label: t('admin.plugins.incompatible'), title } -} - // ── Chip / badge tone helpers (mobile status tokens) ──────────────────────── const CHIP_NEUTRAL = 'text-m-muted border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)]' const CHIP_INFO = 'text-[color:var(--m-st-info)] border-[color:color-mix(in_srgb,var(--m-st-info)_28%,transparent)] bg-[color:color-mix(in_srgb,var(--m-st-info)_10%,transparent)]' @@ -304,20 +70,6 @@ function SideloadedBadge({ t }: { t: T }) { ) } -/** - * Whether a plugin came from the REGISTRY (as opposed to a manual upload or a dev-link). - * - * This is the precedence rule for the trust badges, and it is load-bearing: `signed` - * derives from the pinned author key while sideloaded/dev-linked derive from source_repo, - * so the states are NOT mutually exclusive in the data. A sideloaded plugin genuinely has - * no pinned key, and a naive render would put "Unsigned" *and* "Sideloaded" side by side. - * The source badge wins — it already says something strictly stronger, and doubling up - * dilutes the amber into wallpaper, which is exactly what makes a warning worthless. - */ -function isRegistrySourced(sourceRepo: string | null | undefined): boolean { - return !!sourceRepo && sourceRepo !== 'local:upload' && sourceRepo !== 'local:link' -} - /** * Signed / Unsigned, for registry plugins only (see isRegistrySourced). * @@ -364,429 +116,19 @@ function TypeBadge({ type, t }: { type: string; t: T }) { export default function MAdminPluginsPanel() { const { t, locale } = useTranslation() - const toast = useToast() - const [runtimeOn, setRuntimeOn] = useState(false) - const [devLink, setDevLink] = useState(false) // dev-link enabled server-side (TREK_PLUGINS_DEV_LINK) - const [ignoreTrekRange, setIgnoreTrekRange] = useState(false) // TREK_PLUGINS_IGNORE_TREK_RANGE set server-side - const bypass = useRangeBypass() // its warning dialog/sheet state — shared with the other shell - const [linkPath, setLinkPath] = useState('') - const [plugins, setPlugins] = useState([]) - const [loading, setLoading] = useState(true) - const [error, setError] = useState(false) - const [busy, setBusy] = useState(null) - const [view, setView] = useState<'installed' | 'discover'>('installed') - const [registry, setRegistry] = useState(null) - const [latest, setLatest] = useState>({}) - // The registry entry per id — the re-trust dialog needs the NEW author key from it (in - // full: the server's equality check is exact), and the Discover/consent copy needs `signed`. - const [regById, setRegById] = useState>({}) - // Open when a signature check refused an install/update. Carries the code, so the dialog - // knows whether an override may even be offered. - const [signatureBlock, setSignatureBlock] = useState<{ subject: SigSubject; code: string; detail: string | null } | null>(null) - const [retrusting, setRetrusting] = useState(false) - const [detailFor, setDetailFor] = useState(null) - const [errorsFor, setErrorsFor] = useState<{ id: string; rows: Array<{ ts: string; level: string; message: string }> } | null>(null) - const [egressFor, setEgressFor] = useState<{ id: string; supported: boolean; hosts: string[] } | null>(null) - const [egressDraft, setEgressDraft] = useState('') - const [egressSaving, setEgressSaving] = useState(false) - const [egressError, setEgressError] = useState('') - // The admin-owned scope:'instance' settings sheet — shared logic with the desktop shell. - const settings = useInstanceSettings() - const [confirmUninstall, setConfirmUninstall] = useState(null) - // The version picker for an INSTALLED plugin ("Change version…"); versions land async - // from the registry detail endpoint, which computes each version's compat verdict. - const [versionPicker, setVersionPicker] = useState<{ plugin: PluginRow; versions: VersionInfo[] | null; failed: boolean } | null>(null) - // A picked version OLDER than the installed one — held here until the admin consents - // to the data risk (the plugin's data dir stays; older code may not understand it). - const [confirmDowngrade, setConfirmDowngrade] = useState<{ plugin: PluginRow; version: string } | null>(null) - // A QUEUE, not one slot: "Update All" can produce several re-consent prompts — - // each must be shown, not silently overwritten by the last one. - const [consentQueue, setConsentQueue] = useState>([]) - // Open when enabling a plugin is blocked by missing/outdated plugin dependencies. - const [depResolve, setDepResolve] = useState<{ plugin: PluginRow; missing: PluginDep[]; versionMismatch: VersionMismatch[] } | null>(null) - const [menu, setMenu] = useState(null) - - // Toolbar state. - const [q, setQ] = useState('') - const [typeFilter, setTypeFilter] = useState('all') - const [statusFilter, setStatusFilter] = useState('all') - const [sort, setSort] = useState('name') - - // 'updates' only ranks installed plugins, 'downloads' only the registry — snap - // the key back to name when switching tabs so the dropdown never carries a label - // for an option the active tab can't offer. - useEffect(() => { - if (view === 'discover' && sort === 'updates') setSort('name') - else if (view === 'installed' && sort === 'downloads') setSort('name') - }, [view, sort]) - - // Sideload upload: drag a plugin .zip onto the panel or use the toolbar button. - const [dragActive, setDragActive] = useState(false) - const uploadInputRef = useRef(null) - const dragDepth = useRef(0) - - // Index the registry once per fetch: the version map the update badges read, plus the - // whole entry (author key + signed flag) the trust badges and re-trust dialog need. - // The map holds latestCompatible — the newest version THIS TREK can install (computed - // server-side) — not the absolute latest, so the banner never counts an update the - // update endpoint would refuse. - const indexRegistry = (items: RegistryItem[]) => { - const vers: Record = {} - const byId: Record = {} - items.forEach((i) => { byId[i.id] = i; if (i.latestCompatible) vers[i.id] = i.latestCompatible }) - setLatest(vers) - setRegById(byId) - } - - const refresh = () => { - // Keep the app-wide active-plugin store in sync so widget/hero/tab consumers - // (e.g. the dashboard) reflect an activate/deactivate without a full reload (F5). - void usePluginStore.getState().loadPlugins() - adminApi.plugins() - .then((d: { enabled: boolean; devLink?: boolean; ignoreTrekRange?: boolean; plugins: PluginRow[] }) => { - setRuntimeOn(!!d.enabled) - setDevLink(!!d.devLink) - setIgnoreTrekRange(!!d.ignoreTrekRange) - setPlugins(d.plugins || []) - if ((d.plugins || []).length) { - adminApi.pluginBrowse().then(indexRegistry).catch(() => {}) - } - }) - .catch(() => setError(true)) - .finally(() => setLoading(false)) - } - useEffect(refresh, []) - - // A signature refusal is not an ordinary "action failed": it is the one class of error - // where WHICH failure it was decides what the admin may do about it. Route it to the - // dialog off the CODE, never off the message text. Returns true when handled. - // - // The subject is resolved from wherever it can be — the installed row, else the registry - // entry (a plugin being INSTALLED has no row yet), else the bare id. A signature code - // ALWAYS opens the dialog: falling back to a toast is what used to hide an invalid - // signature on a fresh install behind the same treatment as a network blip. - const routeSignatureError = (id: string, code?: string, detail?: string): boolean => { - if (!code || !SIGNATURE_CODES.includes(code)) return false - const row = plugins.find(p => p.id === id) - const subject: SigSubject = row - ? { id, name: row.name, keyFingerprint: row.keyFingerprint ?? null } - // Not installed: no pinned key exists, so SIGNATURE_KEY_CHANGED cannot arise and the - // dialog shows the no-override explanation — which is exactly right. - : { id, name: regById[id]?.name ?? id, keyFingerprint: null } - setSignatureBlock({ subject, code, detail: detail ?? null }) - return true - } - - const act = async (id: string, fn: () => Promise, ok: string) => { - setBusy(id); setMenu(null) - try { await fn(); toast.success(ok) } - catch (e) { - const { error, code } = errBody(e) - if (!routeSignatureError(id, code, error)) toast.error(error || t('admin.plugins.actionError')) - } - finally { setBusy(null); refresh() } - } - - const openDiscover = () => { - setView('discover') - if (!registry) { - adminApi.pluginBrowse() - .then((items: RegistryItem[]) => { setRegistry(items); indexRegistry(items) }) - .catch(() => setRegistry([])) - } - } - // The rescan/reload button rediscovers locally-installed plugins AND force-pulls - // the remote registry (bypassing the 30-min server cache + GitHub's CDN), so a - // just-published plugin shows up right away instead of up to ~35 min later. - const rescan = () => act('__rescan', async () => { - await adminApi.pluginRescan() - const items: RegistryItem[] = await adminApi.pluginBrowse(true) - setRegistry(items) - indexRegistry(items) - }, t('admin.plugins.rescanned')) - - // Sideload a plugin archive (installs INACTIVE — the admin still consents on activation). - const uploadPlugin = async (file: File) => { - setBusy('__upload'); setMenu(null) - try { - const res = await adminApi.pluginUpload(file) - setView('installed') - toast.success(t('admin.plugins.uploaded', { name: res.id })) - bypass.notice(res.id, res.trekRangeBypassed) - } catch (e) { - toast.error((e as { response?: { data?: { error?: string } } })?.response?.data?.error || t('admin.plugins.actionError')) - } finally { - setBusy(null); refresh() - } - } - const pickUpload = () => uploadInputRef.current?.click() - const onDragEnter = (e: DragEvent) => { - if (!runtimeOn || !Array.from(e.dataTransfer.types).includes('Files')) return - e.preventDefault(); dragDepth.current++; setDragActive(true) - } - // Mirrors onDragEnter: a leave that had no matching enter (a non-file drag) is ignored - // instead of driving the depth counter negative. - const onDragLeave = () => { - if (dragDepth.current === 0) return - if (--dragDepth.current === 0) setDragActive(false) - } - const onDrop = (e: DragEvent) => { - e.preventDefault(); dragDepth.current = 0; setDragActive(false) - if (!runtimeOn) return - const f = e.dataTransfer.files?.[0] - if (f) void uploadPlugin(f) - } - const openEgress = (id: string) => { - setMenu(null) - setEgressDraft(''); setEgressError('') - adminApi.pluginEgressHosts(id) - .then(d => setEgressFor({ id, supported: d.supported, hosts: d.hosts })) - .catch(() => setEgressFor({ id, supported: false, hosts: [] })) - } - - // Saving RE-SPAWNS the plugin: the child's egress guard is installed once at init and - // a second init is refused, so a live child's allow-list can never be widened in place. - const saveEgress = async (hosts: string[]) => { - if (!egressFor) return - setEgressSaving(true); setEgressError('') - try { - const d = await adminApi.pluginSetEgressHosts(egressFor.id, hosts) - setEgressFor({ ...egressFor, hosts: d.hosts }) - setEgressDraft('') - } catch (e) { - const err = e as { response?: { data?: { error?: string } } } - setEgressError(err.response?.data?.error || t('common.error')) - } finally { - setEgressSaving(false) - } - } - - const openInstanceSettings = (p: { id: string; status: string }) => { - setMenu(null) - settings.open(p.id, p.status === 'active') - } - - const openErrors = (id: string) => { - setMenu(null) - adminApi.pluginErrors(id) - .then((d: { errors: Array<{ ts: string; level: string; message: string }> }) => setErrorsFor({ id, rows: d.errors })) - .catch(() => setErrorsFor({ id, rows: [] })) - } - - // A held plugin (deliberate non-latest install) is deliberately NOT an update candidate: - // the admin just rolled it back, and the banner nagging them straight back would make - // the rollback fight the UI. The row carries its own "paused" marker + resume instead. - const updateAvailable = (p: PluginRow) => !!(p.version && !p.updateHold && latest[p.id] && isNewer(latest[p.id], p.version)) - const resumeUpdates = (p: PluginRow) => act(p.id, () => adminApi.pluginResumeUpdates(p.id), t('admin.plugins.updatesResumed')) - // A newer version exists but this TREK can't install it (and it isn't the one on offer): - // said passively on the row, so the admin learns a TREK upgrade unlocks it instead of - // wondering why no update shows. Null when the registry gives no range to point at. - const newerIncompatible = (p: PluginRow): { version: string; range: string } | null => { - const reg = regById[p.id] - if (!reg?.latest || !p.version || !isNewer(reg.latest, p.version)) return null - if (latest[p.id] === reg.latest) return null - const range = reg.trek ?? (reg.minTrekVersion ? `>=${reg.minTrekVersion}` : null) - return range ? { version: reg.latest, range } : null - } - // `warn` is the pre-install confirm for an entry the registry already flagged as - // incompatible; an artifact whose OWN manifest turns out to be out of range (the index - // was only a pre-download filter) is caught by the marker on the response instead. - const install = (id: string, version?: string, warn?: RangeWarning) => bypass.guard(warn, () => - act(id, () => adminApi.pluginInstall(id, version ? { version } : undefined).then(r => { if (!warn) bypass.notice(id, r?.trekRangeBypassed) }), t('admin.plugins.installed'))) - const restart = (id: string) => act(id, async () => { await adminApi.pluginDeactivate(id); await adminApi.pluginActivate(id) }, t('admin.plugins.restarted')) - // Dev-link: register a plugin from a local built directory (dev only). Reuses the - // same busy/toast/refresh loop as uploadPlugin; the server gates it. - const linkLocal = async () => { - const p = linkPath.trim() - if (!p) return - setBusy('__link'); setMenu(null) - try { - const res = await adminApi.pluginLink(p) - setView('installed') - setLinkPath('') - toast.success(t('admin.plugins.devLinkLinked', { id: res.id })) - bypass.notice(res.id, res.trekRangeBypassed) - } catch (e) { - toast.error((e as { response?: { data?: { error?: string } } })?.response?.data?.error || t('admin.plugins.actionError')) - } finally { - setBusy(null); refresh() - } - } - const installedIds = new Set(plugins.map(p => p.id)) - - // Installed-but-disabled direct deps that enabling `p` will auto-enable first. - const autoEnabledDeps = (p: PluginRow) => - (p.dependencies?.pluginDependencies ?? []) - .map(d => plugins.find(x => x.id === d.id)) - .filter((x): x is PluginRow => !!x && x.enabled === 0) - .map(x => x.name) - - // Shared handling for a failed activation: route each 409 code to the right fix - // (consent dialog, download-dependency dialog, or a clear toast). - const onActivateError = (p: PluginRow, e: ActivateErr) => { - const d = e?.response?.data - if (e?.response?.status === 409 && d?.code === 'CONSENT_REQUIRED') { - setConsentQueue(qq => [...qq, { plugin: p, version: latest[p.id] ?? p.version ?? '', newPermissions: d.newPermissions ?? [], newEgress: d.newEgress ?? [] }]) - } else if (e?.response?.status === 409 && d?.code === 'ADDON_DISABLED') { - toast.error(t('admin.plugins.dep.addonDisabledToast', { count: (d.addons ?? []).length, addons: (d.addons ?? []).join(', ') })) - } else if (e?.response?.status === 409 && d?.code === 'DEPENDENCY_MISSING') { - setDepResolve({ plugin: p, missing: d.missing ?? [], versionMismatch: d.versionMismatch ?? [] }) - } else { - // DEPENDENCY_CYCLE and everything else surface their server message. - toast.error(d?.error || t('admin.plugins.actionError')) - } - } - - const attemptActivate = (p: PluginRow) => { - const cascaded = autoEnabledDeps(p) - return adminApi.pluginActivate(p.id) - .then(() => { - toast.success(t('admin.plugins.activated')) - if (cascaded.length) toast.success(t('admin.plugins.dep.autoEnabled', { count: cascaded.length, plugins: cascaded.join(', ') })) - setDepResolve(null) - }) - .catch((e: ActivateErr) => onActivateError(p, e)) - } - - // Enable/disable a plugin. Re-enabling one whose update widened its permissions - // must NOT grant them silently (409 CONSENT_REQUIRED → consent dialog); a disabled - // required addon or a missing plugin dependency (409 ADDON_DISABLED / - // DEPENDENCY_MISSING) routes to the right remedy. - const toggle = (p: PluginRow) => { - if (busy === p.id) return - if (p.enabled === 1) { void act(p.id, () => adminApi.pluginDeactivate(p.id), t('admin.plugins.deactivated')); return } - setBusy(p.id); setMenu(null) - attemptActivate(p).finally(() => { setBusy(null); refresh() }) - } - - // Download a missing/outdated plugin dependency (latest compatible for its range, - // transitively), then retry enabling the plugin that needed it. - const resolveDependency = (parent: PluginRow, depId: string, constraint?: string) => { - if (busy === parent.id) return - setBusy(parent.id) - adminApi.pluginInstall(depId, { constraint, withDependencies: true }) - .then((r: { installed?: string[]; requiredAddons?: string[]; trekRangeBypassed?: TrekRangeBypass | null }) => { - toast.success(t('admin.plugins.dep.downloaded', { id: depId })) - if (r?.requiredAddons?.length) toast.error(t('admin.plugins.dep.addonDisabledToast', { count: r.requiredAddons.length, addons: r.requiredAddons.join(', ') })) - bypass.notice(depId, r?.trekRangeBypassed) - return attemptActivate(parent) - }) - // The DEPENDENCY is what's being downloaded, so a signature refusal here is about the - // dependency's author, not the parent's — route it under depId before falling through - // to the activation-error handling, which knows nothing about signature codes. - .catch((e: ActivateErr) => { - const { error, code } = errBody(e) - if (!routeSignatureError(depId, code, error)) onActivateError(parent, e) - }) - .finally(() => { setBusy(null); refresh() }) - } - - // `version` pins the exact version to install (rollback / explicit switch); omitted, - // the server resolves the newest TREK-compatible version itself. - const runUpdate = (p: PluginRow, version?: string) => { - setBusy(p.id); setMenu(null) - adminApi.pluginUpdate(p.id, version) - .then((r: { version: string; activated: boolean; newPermissions: string[]; newEgress: string[]; trekRangeBypassed?: TrekRangeBypass | null }) => { - if (r.activated || (r.newPermissions.length === 0 && r.newEgress.length === 0)) toast.success(t('admin.plugins.updated')) - else setConsentQueue(qq => [...qq, { plugin: p, version: r.version, newPermissions: r.newPermissions, newEgress: r.newEgress }]) - bypass.notice(p.name, r.trekRangeBypassed) - }) - .catch(e => { - const { error, code } = errBody(e) - if (!routeSignatureError(p.id, code, error)) toast.error(error || t('admin.plugins.actionError')) - }) - .finally(() => { setBusy(null); refresh() }) - } - - // Confirm a key rotation: re-pin the new key AND update, in one server call. There is no - // follow-up /update — a re-pin that waited for a second call would leave the plugin - // pinned to a key no install had ever been verified against if that call never came. - const confirmRetrust = (id: string, version: string, publicKey: string) => { - setRetrusting(true) - adminApi.pluginRetrust(id, version, publicKey) - .then((r: { version: string; activated: boolean; newPermissions: string[]; newEgress: string[] }) => { - setSignatureBlock(null) - // A re-trusted update widening permissions still needs consent — re-trusting a - // signing key says nothing about what the new code is allowed to do. Re-trust only - // ever fires for an INSTALLED plugin, so the row is there to consent against. - const p = plugins.find(x => x.id === id) - if (p && !r.activated && (r.newPermissions.length > 0 || r.newEgress.length > 0)) { - setConsentQueue(qq => [...qq, { plugin: p, version: r.version, newPermissions: r.newPermissions, newEgress: r.newEgress }]) - } else toast.success(t('admin.plugins.retrusted')) - }) - .catch(e => toast.error(errBody(e).error || t('admin.plugins.actionError'))) - .finally(() => { setRetrusting(false); refresh() }) - } - - // "Change version…" on an installed row: fetch the per-version list (with the server's - // compat verdicts) and open the picker. The fetch is keyed to the plugin so a stale - // response can't populate a picker that was reopened for another row. - const openVersionPicker = (p: PluginRow) => { - setMenu(null) - setVersionPicker({ plugin: p, versions: null, failed: false }) - adminApi.pluginDetail(p.id) - .then((d: RegistryDetail) => setVersionPicker(cur => (cur && cur.plugin.id === p.id ? { ...cur, versions: d.versions ?? [] } : cur))) - .catch(() => setVersionPicker(cur => (cur && cur.plugin.id === p.id ? { ...cur, failed: true } : cur))) - } - - // Switching DOWN keeps the plugin's data dir, which the older code may not understand — - // that asks for explicit consent first. Any other switch is the ordinary update path. - const pickVersion = (version: string) => { - if (!versionPicker) return - const p = versionPicker.plugin - setVersionPicker(null) - if (p.version && isNewer(p.version, version)) setConfirmDowngrade({ plugin: p, version }) - else runUpdate(p, version) - } - - // eslint-disable-next-line react-hooks/exhaustive-deps - const updatable = useMemo(() => plugins.filter(updateAvailable), [plugins, latest]) - - // Installed list after search / type / status filters + sort. - const shownInstalled = useMemo(() => { - const term = q.trim().toLowerCase() - let rows = plugins.filter(p => { - const matchesText = !term || `${p.name} ${p.description ?? ''}`.toLowerCase().includes(term) - const matchesType = typeFilter === 'all' || p.type === typeFilter - const st = statusFilter === 'all' ? true - : statusFilter === 'on' ? p.enabled === 1 && p.status !== 'error' - : statusFilter === 'off' ? p.enabled === 0 - : statusFilter === 'update' ? updateAvailable(p) - : p.status === 'error' - return matchesText && matchesType && st - }) - rows = [...rows].sort((a, b) => { - if (sort === 'updates') { - const ua = updateAvailable(a) ? 0 : 1, ub = updateAvailable(b) ? 0 : 1 - if (ua !== ub) return ua - ub - } - return a.name.localeCompare(b.name) - }) - return rows - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [plugins, q, typeFilter, statusFilter, sort, latest]) - - // Registry list after search / type filter. - const shownRegistry = useMemo(() => { - if (!registry) return null - const term = q.trim().toLowerCase() - let items = registry.filter(r => { - const matchesText = !term || `${r.name} ${r.author} ${r.description}`.toLowerCase().includes(term) - const matchesType = typeFilter === 'all' || r.type === typeFilter - return matchesText && matchesType - }) - items = [...items].sort((a, b) => { - if (sort === 'downloads') return (b.downloadCount ?? 0) - (a.downloadCount ?? 0) || a.name.localeCompare(b.name) - if (sort === 'recent') return (Date.parse(b.reviewedAt ?? '') || 0) - (Date.parse(a.reviewedAt ?? '') || 0) || a.name.localeCompare(b.name) - return a.name.localeCompare(b.name) - }) - return items - }, [registry, q, typeFilter, sort]) - - const anyFilter = q.trim() !== '' || typeFilter !== 'all' || statusFilter !== 'all' - - // The plugin whose ⋯ action sheet is open (menu === `row:${id}`). - const rowMenuPlugin = menu?.startsWith('row:') ? plugins.find(p => `row:${p.id}` === menu) ?? null : null + const { + bypass, settings, runtimeOn, devLink, linkPath, setLinkPath, plugins, loading, error, busy, view, setView, + registry, latest, regById, signatureBlock, setSignatureBlock, retrusting, detailFor, setDetailFor, errorsFor, + setErrorsFor, egressFor, setEgressFor, egressDraft, setEgressDraft, egressSaving, egressError, confirmUninstall, + setConfirmUninstall, versionPicker, setVersionPicker, confirmDowngrade, setConfirmDowngrade, consentQueue, + depResolve, setDepResolve, menu, setMenu, q, setQ, typeFilter, setTypeFilter, statusFilter, setStatusFilter, + sort, setSort, dragActive, uploadInputRef, openDiscover, rescan, uploadPlugin, pickUpload, onDragEnter, + onDragLeave, onDrop, openEgress, saveEgress, openInstanceSettings, openErrors, updateAvailable, resumeUpdates, + newerIncompatible, install, restart, linkLocal, installedIds, toggle, runUpdate, openVersionPicker, pickVersion, + updatable, updateAll, shownInstalled, shownRegistry, anyFilter, ignoreTrekRange, rowMenuPlugin, reviewBlock, + askUninstall, uninstallConfirmed, downgradeConfirmed, retrustBlocked, consentUnsigned, approveConsent, + deferConsent, downloadDependency, + } = usePluginsAdmin({ updateAllInParallel: true }) return (
{t('admin.plugins.updatesAvailable', { count: updatable.length })} - {/* p => runUpdate(p), NOT forEach(runUpdate): forEach's index would land in - runUpdate's `version` parameter and pin the update to a number. */} - @@ -933,10 +273,7 @@ export default function MAdminPluginsPanel() { onToggle={() => toggle(p)} onUpdate={() => runUpdate(p)} onResume={() => void resumeUpdates(p)} - onReviewBlock={() => setSignatureBlock({ - subject: { id: p.id, name: p.name, keyFingerprint: p.keyFingerprint ?? null }, - code: p.updateBlock!.code, detail: p.updateBlock!.detail, - })} + onReviewBlock={() => reviewBlock(p)} onEgress={() => openEgress(p.id)} onMenu={() => setMenu(`row:${p.id}`)} /> ))} @@ -968,7 +305,7 @@ export default function MAdminPluginsPanel() { onEgress={() => openEgress(rowMenuPlugin.id)} onSettings={() => openInstanceSettings(rowMenuPlugin)} onChangeVersion={() => openVersionPicker(rowMenuPlugin)} - onUninstall={() => { setMenu(null); setConfirmUninstall(rowMenuPlugin) }} /> + onUninstall={() => askUninstall(rowMenuPlugin)} /> )} {/* Version picker sheet */} @@ -981,7 +318,7 @@ export default function MAdminPluginsPanel() { setConfirmDowngrade(null)} - onConfirm={() => { const d = confirmDowngrade!; setConfirmDowngrade(null); runUpdate(d.plugin, d.version) }} + onConfirm={downgradeConfirmed} title={t('admin.plugins.downgradeTitle')} message={t('admin.plugins.downgradeBody', { from: confirmDowngrade?.plugin.version ?? '', to: confirmDowngrade?.version ?? '' })} confirmLabel={t('admin.plugins.downgradeConfirm')} @@ -1171,10 +508,7 @@ export default function MAdminPluginsPanel() { setConfirmUninstall(null)} - onConfirm={async () => { - const p = confirmUninstall!; setConfirmUninstall(null) - await act(p.id, () => adminApi.pluginUninstall(p.id, true), t('admin.plugins.uninstalled')) - }} + onConfirm={uninstallConfirmed} title={t('admin.plugins.uninstallTitle')} message={t('admin.plugins.uninstallBody')} confirmLabel={t('common.delete')} @@ -1197,7 +531,7 @@ export default function MAdminPluginsPanel() { {signatureBlock && ( confirmRetrust(signatureBlock.subject.id, version, publicKey)} + onRetrust={retrustBlocked} onClose={() => setSignatureBlock(null)} /> )} @@ -1205,28 +539,16 @@ export default function MAdminPluginsPanel() { {consentQueue[0] && ( { - const c = consentQueue[0]; setConsentQueue(qq => qq.slice(1)) - // consent:true — the ONLY path that may widen a plugin's granted rights. - await act(c.plugin.id, () => adminApi.pluginActivate(c.plugin.id, true), t('admin.plugins.updated')) - }} - onLater={() => { setConsentQueue(qq => qq.slice(1)); toast.success(t('admin.plugins.updateKeptOff')) }} + unsigned={consentUnsigned} + onApprove={approveConsent} + onLater={deferConsent} /> )} {depResolve && ( resolveDependency(depResolve.plugin, depId, constraint)} + onDownload={downloadDependency} onClose={() => setDepResolve(null)} /> )} @@ -1272,16 +594,6 @@ function FilterPill({ label, value, active, icon, onClick }: { label: string; va function typeLabel(f: TypeFilter, t: T): string { return f === 'all' ? t('admin.plugins.allTypes') : t(`admin.plugins.type.${f}` as never) } -function statusLabel(s: StatusFilter, t: T): string { - return s === 'all' ? t('admin.plugins.allStatuses') : s === 'on' ? t('admin.plugins.status.active') - : s === 'off' ? t('admin.plugins.stateOff') : s === 'update' ? t('admin.plugins.filterUpdate') : t('admin.plugins.status.error') -} -function sortLabel(s: SortKey, t: T): string { - return s === 'name' ? t('admin.plugins.sortName') - : s === 'recent' ? t('admin.plugins.sortRecent') - : s === 'downloads' ? t('admin.plugins.sortDownloads') - : t('admin.plugins.sortUpdates') -} function PickerSheet({ open, onClose, title, options, value, onPick }: { open: boolean; onClose: () => void; title: string @@ -1633,14 +945,6 @@ function RegistryList({ items, onInstall, onOpenDetail, busy, t, installedIds, f ) } -// 1234 -> "1.2k" — GitHub-style compact download counts for the browse cards. -// The M threshold sits at the k-rounding boundary so 999 950 is "1M", not "1000k". -function formatCompactCount(n: number): string { - if (n >= 999_500) return `${Math.round(n / 100_000) / 10}M` - if (n >= 1000) return `${Math.round(n / 100) / 10}k` - return String(n) -} - // A permission rendered human-readable when known, else as its raw code. function PermLabel({ perm, t }: { perm: string; t: T }) { return PERM_KEYS.includes(perm) @@ -1652,16 +956,7 @@ function PluginDetailSheet({ item, installed, busy, onInstall, onClose, t, local item: RegistryItem; installed: boolean; busy: string | null; ignoreTrekRange: boolean onInstall: (id: string, version?: string, warn?: RangeWarning) => void; onClose: () => void; t: T; locale: string }) { - const [detail, setDetail] = useState(null) - const [failed, setFailed] = useState(false) - - useEffect(() => { - let alive = true - adminApi.pluginDetail(item.id) - .then((d: RegistryDetail) => { if (alive) setDetail(d) }) - .catch(() => { if (alive) setFailed(true) }) - return () => { alive = false } - }, [item.id]) + const { detail, failed } = usePluginDetail(item.id) const manifest = detail?.manifest ?? null const caps = manifest ? deriveCaps(manifest.permissions, manifest.capabilities ?? {}, t) : [] @@ -1893,17 +1188,8 @@ function SignatureBlockSheet({ data, entry, busy, t, onRetrust, onClose }: { onRetrust: (version: string, publicKey: string) => void onClose: () => void }) { - const canRetrust = data.code === RETRUSTABLE - const newKey = entry?.authorPublicKey ?? null - const version = entry?.latest ?? null - // Only offer the override when we actually hold the new key + version to send: the - // request carries both, and the server compares the key exactly. - const offerRetrust = canRetrust && !!newKey && !!version - - const bodyKey = data.code === RETRUSTABLE ? 'admin.plugins.sig.keyChangedBody' - : data.code === 'SIGNATURE_MISSING' ? 'admin.plugins.sig.missingBody' - : data.code === 'SIGNATURE_INCOMPLETE' ? 'admin.plugins.sig.incompleteBody' - : 'admin.plugins.sig.invalidBody' + const { canRetrust, newKey, retrust, bodyKey } = signatureBlockView(data.code, entry) + const offerRetrust = retrust !== null return ( @@ -1934,8 +1220,8 @@ function SignatureBlockSheet({ data, entry, busy, t, onRetrust, onClose }: { {offerRetrust ? t('admin.plugins.sig.cancel') : t('common.close')} - {offerRetrust && ( - onRetrust(version, newKey)}> + {retrust && ( + onRetrust(retrust.version, retrust.newKey)}> {t('admin.plugins.sig.retrustConfirm')} )} @@ -1953,15 +1239,6 @@ function KeyRow({ label, value, highlight }: { label: string; value: string; hig ) } -/** Client-side twin of the server's fingerprint: head…tail of the base64 payload. Display - * only — every equality check that matters happens server-side against the full key. */ -function fingerprint(key: string | null): string | null { - if (!key) return null - const payload = key.trim().split(/\r?\n/).map(l => l.trim()).filter(l => l && !l.startsWith('untrusted comment')).pop() - if (!payload) return null - return payload.length <= 20 ? payload : `${payload.slice(0, 8)}…${payload.slice(-8)}` -} - function UpdateConsentSheet({ data, unsigned, t, onApprove, onLater }: { data: { plugin: PluginRow; version: string; newPermissions: string[]; newEgress: string[] } unsigned: boolean @@ -2023,10 +1300,7 @@ function DependencyResolveSheet({ data, t, busy, installedIds, onDownload, onClo t: T; busy: boolean; installedIds: Set onDownload: (depId: string, constraint?: string) => void; onClose: () => void }) { - const rows: Array<{ id: string; constraint: string; installed?: string }> = [ - ...data.missing.map(d => ({ id: d.id, constraint: d.version })), - ...data.versionMismatch.map(d => ({ id: d.id, constraint: d.wanted, installed: d.installed })), - ] + const rows = dependencyRows(data) return (