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 (