import { useEffect, useMemo, useRef, useState, type CSSProperties, type DragEvent } from 'react' import { PLUGIN_PERMISSIONS } from '@trek/shared' import { createPortal } from 'react-dom' import { Blocks, AlertTriangle, PackageOpen, RefreshCw, Trash2, Download, Bug, X, ShieldCheck, UploadCloud, ArrowUpCircle, Github, ExternalLink, ChevronDown, Check, Lock, Search, Link2, KeyRound, ShieldAlert, SlidersHorizontal, ArrowUpDown, CircleDot, MoreHorizontal, RotateCw, ArrowRight, Database, Users, LayoutDashboard, Radio, Luggage, Globe, Image, CalendarDays, Bell, Info, History, PauseCircle, Wallet, Puzzle, MapPin, ListChecks, Pencil, Tag, FileText, Route, Navigation, Clock, LocateFixed, Palette, } from 'lucide-react' import PluginIcon from '../shared/PluginIcon' import { adminApi } from '../../api/client' import { usePluginStore } from '../../store/pluginStore' import { useTranslation } from '../../i18n' import { useToast } from '../shared/Toast' import ConfirmDialog from '../shared/ConfirmDialog' import ToggleSwitch from '../Settings/ToggleSwitch' /** * Admin → Plugins (#plugins). A full plugin-management surface: a segmented * Installed/Discover switch, a toolbar (search + type/status filters + sort), an * updates bar, tidy installed rows that surface each plugin's real reach as * capability chips, an App-Store-style registry grid, an enriched detail dialog, * and the update re-consent gate. Isolation health shows as a dot on the icon * 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 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 /** 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[] } } } | 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', starting: 'bg-info animate-pulse', error: 'bg-danger', inactive: 'bg-content-faint/60', disabled: 'bg-warning', 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 Cap { icon: React.ComponentType<{ size?: number; className?: string }>; label: string; net?: boolean } // Turn a plugin's declared permissions + capabilities into the at-a-glance chips // that make its real reach legible without opening the detail dialog. function deriveCaps(perms: string[], caps: { widget?: { slot?: string }; tripPage?: { replaces?: string[] } }, t: T): Cap[] { const out: Cap[] = [] if (perms.includes('db:read:trips')) out.push({ icon: Database, label: t('admin.plugins.cap.readsTrips') }) if (perms.includes('db:read:users')) out.push({ icon: Users, label: t('admin.plugins.cap.readsUsers') }) if (perms.includes('db:write:costs')) out.push({ icon: Wallet, label: t('admin.plugins.cap.writesCosts') }) else if (perms.includes('db:read:costs')) out.push({ icon: Wallet, label: t('admin.plugins.cap.readsCosts') }) if (perms.includes('db:read:packing')) out.push({ icon: Luggage, label: t('admin.plugins.cap.readsPacking') }) if (perms.includes('db:read:files')) out.push({ icon: FileText, label: t('admin.plugins.cap.readsFiles') }) if (perms.includes('db:write:places')) out.push({ icon: MapPin, label: t('admin.plugins.cap.writesPlaces') }) if (perms.includes('db:write:days')) out.push({ icon: CalendarDays, label: t('admin.plugins.cap.writesDays') }) if (perms.includes('db:write:itinerary')) out.push({ icon: ListChecks, label: t('admin.plugins.cap.writesItinerary') }) if (perms.includes('db:write:trips')) out.push({ icon: Pencil, label: t('admin.plugins.cap.writesTrips') }) if (perms.includes('db:meta')) out.push({ icon: Tag, label: t('admin.plugins.cap.metadata') }) if (caps.widget) { const slotKey = caps.widget.slot === 'hero' ? 'admin.plugins.cap.heroWidget' : caps.widget.slot === 'place-detail' ? 'admin.plugins.cap.placeSlot' : caps.widget.slot === 'day-detail' ? 'admin.plugins.cap.daySlot' : caps.widget.slot === 'reservation-detail' ? 'admin.plugins.cap.reservationSlot' : 'admin.plugins.cap.widget' out.push({ icon: LayoutDashboard, label: t(slotKey as never) }) } // Replacing planner tabs is the one capability that HIDES core UI — always chip it. if (caps.tripPage?.replaces?.length) out.push({ icon: LayoutDashboard, label: t('admin.plugins.cap.replacesTabs') }) if (perms.some(p => p.startsWith('ws:broadcast'))) out.push({ icon: Radio, label: t('admin.plugins.cap.realtime') }) if (perms.includes('hook:photo-provider')) out.push({ icon: Image, label: t('admin.plugins.cap.photos') }) if (perms.includes('hook:calendar-source')) out.push({ icon: CalendarDays, label: t('admin.plugins.cap.calendar') }) if (perms.includes('hook:place-detail-provider')) out.push({ icon: MapPin, label: t('admin.plugins.cap.placeDetails') }) if (perms.includes('hook:trip-warning-provider')) out.push({ icon: AlertTriangle, label: t('admin.plugins.cap.warnings') }) if (perms.includes('hook:map-layer-provider')) out.push({ icon: Route, label: t('admin.plugins.cap.mapLayers') }) if (perms.includes('hook:route-provider')) out.push({ icon: Navigation, label: t('admin.plugins.cap.routing') }) if (perms.includes('hook:day-schedule-provider')) out.push({ icon: Clock, label: t('admin.plugins.cap.daySchedule') }) if (perms.includes('hook:day-tint-provider')) out.push({ icon: Palette, label: t('admin.plugins.cap.dayTint') }) if (perms.includes('geolocation:read')) out.push({ icon: LocateFixed, label: t('admin.plugins.cap.geolocation') }) if (perms.includes('hook:notification-channel')) out.push({ icon: Bell, label: t('admin.plugins.cap.notificationChannel') }) if (perms.includes('events:subscribe')) out.push({ icon: Radio, label: t('admin.plugins.cap.events') }) for (const h of perms.filter(p => p.startsWith('http:outbound:')).map(p => p.slice('http:outbound:'.length)).filter(Boolean)) { out.push({ icon: ArrowRight, label: h, net: true }) } return out } interface DepChip { icon: React.ComponentType<{ size?: number; className?: string }>; label: string; blocked: 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, }) } 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): { blocked: boolean; version?: string; label: string; title?: string } { 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 (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 } } function ReviewedBadge({ t }: { t: T }) { return } /** Marks a manually-uploaded (sideloaded) plugin: no registry, unsigned, not reviewed. */ function SideloadedBadge({ t }: { t: T }) { return ( {t('admin.plugins.sideloaded')} ) } /** * 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). * * Signed is a quiet neutral tick, NOT a green celebration; unsigned is an amber note, NOT * a red alarm. Roughly two thirds of the live registry is unsigned, so an alarming * treatment would fire on most of the catalog and teach admins to ignore it — and the * honest delta is small: sha256 proves the bytes are what the REGISTRY vouches for, a * signature proves they came from the AUTHOR. Unsigned is one fewer guarantee, not "unsafe". */ function TrustBadge({ signed, t }: { signed: boolean; t: T }) { if (signed) { return ( {t('admin.plugins.signed')} ) } return ( {t('admin.plugins.unsigned')} ) } /** Marks a dev-linked plugin: loaded from a local build dir + hot-reloaded (dev only). */ function DevLinkBadge({ t }: { t: T }) { return ( {t('admin.plugins.devLinkBadge')} ) } function TypeBadge({ type, t }: { type: string; t: T }) { return ( {KNOWN_TYPES.includes(type) ? t(`admin.plugins.type.${type}` as never) : type} ) } 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 [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('') 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; plugins: PluginRow[] }) => { setRuntimeOn(!!d.enabled) setDevLink(!!d.devLink) 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 })) } 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 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 } const install = (id: string, version?: string) => act(id, () => adminApi.pluginInstall(id, version ? { version } : undefined), 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 })) } 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', { 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', { 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[] }) => { toast.success(t('admin.plugins.dep.downloaded', { id: depId })) if (r?.requiredAddons?.length) toast.error(t('admin.plugins.dep.addonDisabledToast', { addons: r.requiredAddons.join(', ') })) 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[] }) => { 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 }]) }) .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' return (
{ if (dragActive) e.preventDefault() }} onDragLeave={onDragLeave} onDrop={onDrop}> {/* Hidden input for the toolbar "Upload plugin" button (drag-drop uses the same handler). */} { const f = e.target.files?.[0]; if (f) void uploadPlugin(f); e.target.value = '' }} /> {/* Drag-to-install overlay */} {dragActive && (
{t('admin.plugins.dropToUpload')}
)} {/* Click-away layer for any open dropdown (filters or a row's ⋯ menu). */} {menu &&
setMenu(null)} />} {/* Header */}

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

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

{runtimeOn && ( {t('admin.plugins.runtimeOn')} )}
{/* Runtime-disabled notice */} {!runtimeOn && !loading && !error && (

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

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

)} {/* Dev-link: register + hot-reload a plugin from a local build dir (dev only). */} {devLink && runtimeOn && !loading && !error && (
{ e.preventDefault(); void linkLocal() }} className="mx-4 sm:mx-6 mt-4 p-3 rounded-xl border border-accent/30 bg-accent-soft flex flex-col sm:flex-row sm:items-center gap-2.5">
{t('admin.plugins.devLinkTitle')}
setLinkPath(e.target.value)} spellCheck={false} placeholder={t('admin.plugins.devLinkPathPlaceholder')} className="flex-1 min-w-0 h-[34px] px-2.5 rounded-lg border border-edge bg-surface-card text-sm text-content placeholder:text-content-faint focus:outline-none focus:border-accent" />
)} {/* Toolbar — on mobile: tabs+rescan, then full-width search, then a right-aligned filter row. On sm+ the wrappers collapse (sm:contents) so everything sits in one wrapping row. */} {runtimeOn && !loading && !error && (
setView('installed')} label={t('admin.plugins.installed')} count={plugins.length} />
setQ(e.target.value)} type="search" placeholder={t('admin.plugins.searchPlaceholder')} className="w-full h-[38px] pl-9 pr-3 rounded-xl border border-edge bg-surface-secondary text-sm text-content placeholder:text-content-faint outline-none focus:bg-surface-card focus:border-content-faint transition-colors" />
} options={[ ['all', t('admin.plugins.allTypes')], ['widget', t('admin.plugins.type.widget')], ['integration', t('admin.plugins.type.integration')], ['page', t('admin.plugins.type.page')], ['trip-page', t('admin.plugins.type.trip-page')], ]} valueLabel={typeFilter === 'all' ? t('admin.plugins.allTypes') : t(`admin.plugins.type.${typeFilter}` as never)} onPick={v => setTypeFilter(v as TypeFilter)} /> {view === 'installed' && ( } options={[ ['all', t('admin.plugins.allStatuses')], ['on', t('admin.plugins.status.active')], ['off', t('admin.plugins.stateOff')], ['update', t('admin.plugins.filterUpdate')], ['err', t('admin.plugins.status.error')], ]} valueLabel={statusLabel(statusFilter, t)} onPick={v => setStatusFilter(v as StatusFilter)} /> )} } options={view === 'discover' ? [['name', t('admin.plugins.sortName')], ['recent', t('admin.plugins.sortRecent')], ['downloads', t('admin.plugins.sortDownloads')]] : [['name', t('admin.plugins.sortName')], ['recent', t('admin.plugins.sortRecent')], ['updates', t('admin.plugins.sortUpdates')]]} valueLabel={sortLabel(sort, t)} onPick={v => setSort(v as SortKey)} />
)} {/* Body */}
{loading ? (
{t('common.loading')}
) : error ? (
{t('admin.plugins.loadError')}
) : !runtimeOn ? null : view === 'discover' ? ( ) : plugins.length === 0 ? ( ) : (
{updatable.length > 0 && statusFilter !== 'err' && (
{t('admin.plugins.updatesAvailable', { count: updatable.length })}
)} {shownInstalled.length === 0 ? (

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

) : shownInstalled.map(p => ( toggle(p)} onUpdate={() => 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, })} onErrors={() => openErrors(p.id)} onEgress={() => openEgress(p.id)} onUninstall={() => { setMenu(null); setConfirmUninstall(p) }} /> ))}
)}
{/* Registry detail dialog */} {detailFor && ( setDetailFor(null)} /> )} {/* Error-log modal */} {errorsFor && (
setErrorsFor(null)}>
e.stopPropagation()}>
{errorsFor.id} — {t('admin.plugins.errorLog')}
{errorsFor.rows.length === 0 ?

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

: errorsFor.rows.map((r, i) => (
{r.level} {r.ts} {r.message}
))}
)} {/* Operator-supplied egress hosts */} {egressFor && (
setEgressFor(null)}>
e.stopPropagation()}>
{egressFor.id} — {t('admin.plugins.allowedHosts')}
{!egressFor.supported ? (

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

) : ( <>

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

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

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

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

{egressError}

}

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

)}
)} setConfirmUninstall(null)} onConfirm={async () => { const p = confirmUninstall!; setConfirmUninstall(null) await act(p.id, () => adminApi.pluginUninstall(p.id, true), t('admin.plugins.uninstalled')) }} title={t('admin.plugins.uninstallTitle')} message={t('admin.plugins.uninstallBody')} /> {versionPicker && ( setVersionPicker(null)} /> )} setConfirmDowngrade(null)} onConfirm={() => { const d = confirmDowngrade!; setConfirmDowngrade(null); void runUpdate(d.plugin, d.version) }} title={t('admin.plugins.downgradeTitle')} message={t('admin.plugins.downgradeBody', { from: confirmDowngrade?.plugin.version ?? '', to: confirmDowngrade?.version ?? '' })} confirmLabel={t('admin.plugins.downgradeConfirm')} /> {signatureBlock && ( confirmRetrust(signatureBlock.subject.id, version, publicKey)} onClose={() => setSignatureBlock(null)} /> )} {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')) }} /> )} {depResolve && ( resolveDependency(depResolve.plugin, depId, constraint)} onClose={() => setDepResolve(null)} /> )}
) } 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 SegBtn({ active, onClick, label, count }: { active: boolean; onClick: () => void; label: string; count?: number }) { return ( ) } function FilterMenu({ id, label, valueLabel, options, onPick, value, menu, setMenu, icon }: { id: string; label: string; valueLabel: string options: Array<[string, string]>; onPick: (v: string) => void; value: string menu: string | null; setMenu: (v: string | null) => void; icon?: React.ReactNode }) { const open = menu === id const active = value !== options[0]?.[0] return (
{open && (
{options.map(([v, lbl]) => ( ))}
)}
) } function EmptyState({ t, onDiscover }: { t: T; onDiscover: () => void }) { return (

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

) } function InstalledRow({ p, t, busy, menu, setMenu, hasUpdate, latestVer, newerIncompatible, blocked, onToggle, onUpdate, onRestart, onChangeVersion, onResume, onReviewBlock, onErrors, onEgress, onUninstall }: { p: PluginRow; t: T; busy: string | null; menu: string | null; setMenu: (v: string | null) => void hasUpdate: boolean; latestVer?: string; newerIncompatible: { version: string; range: string } | null; blocked: boolean onToggle: () => void; onUpdate: () => void; onRestart: () => void; onChangeVersion: () => void; onResume: () => void; onReviewBlock: () => void onErrors: () => void; onEgress: () => void; onUninstall: () => void }) { const caps = deriveCaps(parseJson(p.permissions, []), parseJson<{ widget?: { slot?: string } }>(p.capabilities, {}), t) const deps = deriveDeps(p, t) const menuOpen = menu === `row:${p.id}` const menuBtnRef = useRef(null) // The menu is portaled to and positioned `fixed` against the ⋯ button, // because the row's ancestor (PageSidebar) is `overflow-hidden` and would clip // an in-flow `absolute` menu — losing Delete on the lower rows of a long list. // Re-anchor while the page moves under it. const [, reanchor] = useState(0) useEffect(() => { if (!menuOpen) return const onMove = () => reanchor(n => n + 1) window.addEventListener('scroll', onMove, true) window.addEventListener('resize', onMove) return () => { window.removeEventListener('scroll', onMove, true) window.removeEventListener('resize', onMove) } }, [menuOpen]) return (
{p.name} {p.version && v{p.version}} {p.reviewed_at && } {p.source_repo === 'local:upload' && } {p.source_repo === 'local:link' && } {/* Registry plugins only — a sideloaded/dev-linked plugin already says something strictly stronger, and stacking "Unsigned" on top of it just dilutes the amber. */} {isRegistrySourced(p.source_repo) && }
{p.description &&

{p.description}

} {/* A refused update leaves a WORKING plugin pinned at its old version — so this is its own row state, not an error state, and it persists until it is resolved rather than dying with the toast that first reported it. */} {blocked && p.updateBlock && (
{t('admin.plugins.updateBlocked', { reason: p.updateBlock.detail ?? p.updateBlock.code })}
)} {/* A newer version this TREK can't run — informational, never a button: the fix is a TREK upgrade, so the row must not nag or offer a doomed install. */} {newerIncompatible && (
{t('admin.plugins.newerNeedsTrek', { version: newerIncompatible.version, range: newerIncompatible.range })}
)} {/* Held: the admin deliberately installed a non-latest version, so updates are paused rather than nagged — resuming is one click, right where the pause shows. */} {p.updateHold && (
{t('admin.plugins.updatesHeld', { version: p.version ?? '' })}
)} {p.status === 'error' && p.last_error ? (
{p.last_error}
) : (caps.length > 0 || p.operatorEgress) && (
{caps.map((c, i) => ( {c.label} ))} {/* This plugin talks to a service only the OPERATOR can name (a self-hosted Gotify/ntfy), so its manifest can't list the host — the admin adds it. Actionable, and warning-toned until at least one host exists, because until then the plugin cannot reach anything and looks silently broken. */} {p.operatorEgress && ( )}
)} {deps.length > 0 && (
{deps.map((d, i) => ( {d.label} ))}
)}
{hasUpdate && ( )} {p.enabled === 1 ? t('admin.plugins.status.active') : t('admin.plugins.stateOff')}
{menuOpen && createPortal(
{p.enabled === 1 && ( } label={t('admin.plugins.restart')} onClick={onRestart} /> )} } label={t('admin.plugins.viewErrors')} onClick={onErrors} /> } label={t('admin.plugins.allowedHosts')} onClick={onEgress} /> {/* Registry plugins only — a sideload/dev-link has no registry versions to pick from. */} {isRegistrySourced(p.source_repo) && ( } label={t('admin.plugins.changeVersion')} onClick={onChangeVersion} /> )} {p.source_repo && p.source_repo !== 'local:upload' && p.source_repo !== 'local:link' && ( <> setMenu(null)} className="w-full flex items-center gap-2.5 px-2.5 py-2 rounded-lg text-[13px] text-content-secondary hover:bg-surface-tertiary transition-colors"> {t('admin.plugins.sourceRepo')} setMenu(null)} className="w-full flex items-center gap-2.5 px-2.5 py-2 rounded-lg text-[13px] text-content-secondary hover:bg-surface-tertiary transition-colors"> {t('admin.plugins.reportIssue')} )}
} label={t('common.delete')} danger onClick={onUninstall} />
, document.body, )}
) } /** Tallest the ⋯ menu gets (6 items + divider + padding). Drives the flip. */ const ROW_MENU_MAX_H = 260 /** Pin a portaled menu to the right edge of its trigger, flipping up when the bottom is tight. */ function anchorMenu(trigger: HTMLElement | null): CSSProperties { const r = trigger?.getBoundingClientRect() if (!r) return { position: 'fixed', top: 0, right: 0, zIndex: 100 } const spaceBelow = window.innerHeight - r.bottom const openUp = spaceBelow < ROW_MENU_MAX_H && r.top > spaceBelow return { position: 'fixed', right: Math.max(8, window.innerWidth - r.right), ...(openUp ? { bottom: window.innerHeight - r.top + 4 } : { top: r.bottom + 4 }), zIndex: 100, } } function MenuItem({ icon, label, onClick, danger }: { icon: React.ReactNode; label: string; onClick: () => void; danger?: boolean }) { return ( ) } function Screenshot({ url, className, iconSize = 28 }: { url: string | null; className: string; iconSize?: number }) { const [failed, setFailed] = useState(false) return (
{url && !failed ? ( setFailed(true)} /> ) : (
)}
) } function RegistryGrid({ items, onInstall, onOpenDetail, busy, t, installedIds, filtered }: { items: RegistryItem[] | null onInstall: (id: string, version?: string) => void onOpenDetail: (item: RegistryItem) => void busy: string | null t: T installedIds: Set filtered: boolean }) { if (!items) return
{t('common.loading')}
if (items.length === 0) return (

{filtered ? t('admin.plugins.noMatchRegistry') : t('admin.plugins.registryEmpty')}

) return (
{items.map(item => { const installed = installedIds.has(item.id) const offer = installOffer(item, t) return (
onOpenDetail(item)} onKeyDown={e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onOpenDetail(item) } }} className="group border border-edge rounded-2xl bg-surface-card overflow-hidden flex flex-col cursor-pointer hover:-translate-y-0.5 hover:shadow-elevated hover:border-edge-faint transition-all duration-150">
{item.reviewedAt && ( {t('admin.plugins.reviewed')} )}
{item.name} {item.author}

{item.description}

{/* Everything in Discover is registry-sourced, so the badge always applies. */} {item.latest && v{item.latest}} {typeof item.downloadCount === 'number' && item.downloadCount > 0 && ( {formatCompactCount(item.downloadCount)} )}
) })}
) } // 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) ? {t(`admin.plugins.perm.${perm}` as never)} : {perm} } function PluginDetailModal({ item, installed, busy, onInstall, onClose, t, locale }: { item: RegistryItem; installed: boolean; busy: string | null onInstall: (id: string, version?: string) => 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 manifest = detail?.manifest ?? null const permissions = manifest?.permissions ?? [] const egress = manifest?.egress ?? [] const settings = manifest?.settings ?? [] const caps = manifest ? deriveCaps(permissions, manifest.capabilities ?? {}, t) : [] const repoUrl = `https://github.com/${item.repo}` const homepage = item.homepage && /^https?:\/\//i.test(item.homepage) && item.homepage !== repoUrl ? item.homepage : null const sizeKb = detail?.size ? Math.max(1, Math.round(detail.size / 1024)) : null // The detail fetch carries the same compat verdict as the browse list; prefer it once // it lands (it is keyed to the same entry) and fall back to the grid item until then. const offer = installOffer(detail ?? item, t) return (
e.stopPropagation()}>

{item.name}

{item.reviewedAt && }

{item.author}{item.latest ? ` · v${item.latest}` : ''}

{item.description}

{failed &&

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

} {/* The reason the button is blocked (or offering an older version) — a tooltip alone would leave a touch user with a dead button and no explanation. */} {offer.title && !installed && (

{offer.title}

)} {manifest && (

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

{caps.filter(c => !c.net).length === 0 && !permissions.includes('db:own') ? (

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

) : (
{caps.filter(c => !c.net).map((c, i) => (
{c.label}
))} {permissions.includes('db:own') && (
{t('admin.plugins.perm.db:own')}
)}
)}
)} {manifest && (egress.length > 0 || manifest.operatorEgress) && (

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

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

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

)}
)} {manifest && settings.length > 0 && (

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

    {settings.map(s => (
  • {s.label} {t(`admin.plugins.scope.${s.scope}` as never)} {s.required && {t('admin.plugins.fieldRequired')}}
  • ))}
)}

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

{item.latest && } {sizeKb !== null && } {/* The range, not just its lower bound: "TREK 3.2.0+" reads as "and anything newer", which is exactly the claim a `<4.0.0` upper bound denies. */} {(item.trek || item.minTrekVersion) && ( )} {item.reviewedAt && } {typeof item.downloadCount === 'number' && item.downloadCount > 0 && ( )}
{/* Every published version, installable individually. The compat verdict per version is SERVER-computed — an incompatible one is explained, never offered. */} {(detail?.versions?.length ?? 0) > 1 && (

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

{detail!.versions!.map(v => (
v{v.version} {v.publishedAt && {new Date(v.publishedAt).toLocaleDateString(locale)}} {v.signed && } {v.compatible ? ( !installed && ( ) ) : ( {t('admin.plugins.versionNeedsTrek', { range: v.trek ?? '' })} )}
))}
)}
) } function Meta({ k, v }: { k: string; v: string }) { return
{k}
{v}
} /** * "Change version…" for an INSTALLED plugin. Rows render the server's per-version compat * verdict: the installed version is marked, a compatible one gets a Switch action (the * caller routes a DOWNGRADE through the data-risk confirm), an incompatible one is * explained and never offered. */ function VersionPickerDialog({ plugin, versions, failed, busy, t, locale, onPick, onClose }: { plugin: PluginRow; versions: VersionInfo[] | null; failed: boolean; busy: string | null t: T; locale: string; onPick: (version: string) => void; onClose: () => void }) { return (
e.stopPropagation()}>

{t('admin.plugins.versionPickerTitle', { name: plugin.name })}

{failed &&

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

} {!failed && !versions &&

{t('common.loading')}

} {versions?.length === 0 &&

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

} {versions?.map(v => { const current = v.version === plugin.version return (
v{v.version} {v.publishedAt && {new Date(v.publishedAt).toLocaleDateString(locale)}} {v.signed && } {current ? ( {t('admin.plugins.installed')} ) : v.compatible ? ( ) : ( {t('admin.plugins.versionNeedsTrek', { range: v.trek ?? '' })} )}
) })}
) } /** * A signature check refused an install/update. * * The override lives here, and it is scoped to exactly one code. A rotated key has a * benign explanation — the author rotated it, or lost it and made a new one. A signature * that does NOT verify does not: it means the bytes are not what the author signed, which * is corruption or an attack, and there is no story where the right answer is "let the * admin wave it through". So SIGNATURE_INVALID / _MISSING / _INCOMPLETE get a clear * explanation and no override button AT ALL — not a disabled one, not one behind a * confirm. The absence of an escape hatch is the feature. * * (The server enforces this too — it re-derives the condition and refuses anything but a * changed key. This dialog is the convenience, not the control.) */ function SignatureBlockDialog({ data, entry, busy, t, onRetrust, onClose }: { data: { subject: SigSubject; code: string; detail: string | null } entry: RegistryItem | undefined busy: boolean; t: T 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' return (
e.stopPropagation()}>

{t('admin.plugins.sig.title', { name: data.subject.name })}

{t(bodyKey as never)}

{/* Fingerprints, not full keys: these exist to be COMPARED by a human — read the new one back to the author over the phone. The full key travels in the request. */} {canRetrust && (

{t('admin.plugins.sig.confirmOutOfBand')}

)} {!canRetrust && data.detail && (

{data.detail}

)}
{offerRetrust && ( )}
) } function KeyRow({ label, value, highlight }: { label: string; value: string; highlight?: boolean }) { return (
{label} {value}
) } /** 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 t: T; onApprove: () => void; onLater: () => void }) { return (
e.stopPropagation()}>

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

{t('admin.plugins.updateConsentBody', { name: data.plugin.name, version: data.version })}

{/* The admin is about to widen what this code may do — so say, right here, that nothing ties this code to its author. One line, no checkbox, no extra click: this informs, it does not block. */} {unsigned && (

{t('admin.plugins.sig.consentUnsigned')}

)} {data.newPermissions.length > 0 && (

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

    {data.newPermissions.map(perm => (
  • ))}
)} {data.newEgress.length > 0 && (

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

{data.newEgress.map(host => {host})}
)}
) } // Shown when enabling a plugin is blocked by missing/outdated plugin dependencies. // Each dependency gets a one-click download (latest version satisfying its range, // transitively) that then retries enabling the plugin. function DependencyResolveDialog({ data, t, busy, installedIds, onDownload, onClose }: { data: { plugin: PluginRow; missing: PluginDep[]; versionMismatch: VersionMismatch[] } 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 })), ] return (
e.stopPropagation()}>

{t('admin.plugins.dep.resolveTitle')}

{t('admin.plugins.dep.resolveBody', { name: data.plugin.name })}

{rows.map(r => (
{r.id}
{r.installed ? t('admin.plugins.dep.mismatch', { wanted: r.constraint, installed: r.installed }) : t('admin.plugins.dep.requires', { version: r.constraint })}
))} {rows.some(r => !installedIds.has(r.id)) && (

{t('admin.plugins.dep.resolveHint')}

)}
) } // Footer: a plain-language note on what "Reviewed" means, plus a collapsible // panel that lays out how plugins are contained, the limits, and the worst case. function SecurityInfo({ t }: { t: T }) { const [open, setOpen] = useState(false) const sections: Array<[string, string]> = [ ['admin.plugins.security.isolationTitle', 'admin.plugins.security.isolationBody'], ['admin.plugins.security.permsTitle', 'admin.plugins.security.permsBody'], ['admin.plugins.security.limitsTitle', 'admin.plugins.security.limitsBody'], ['admin.plugins.security.worstTitle', 'admin.plugins.security.worstBody'], ['admin.plugins.security.reviewedTitle', 'admin.plugins.security.reviewedBody'], ['admin.plugins.security.signedTitle', 'admin.plugins.security.signedBody'], ['admin.plugins.security.trustTitle', 'admin.plugins.security.trustBody'], ] return (

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

{open && (
{sections.map(([h, b]) => (

{t(h as never)}

{t(b as never)}

))}
)}
) }