import { useEffect, useRef, useState, type CSSProperties, type ReactNode } from 'react' import { createPortal } from 'react-dom' import { Blocks, AlertTriangle, RefreshCw, Trash2, Download, Bug, ShieldCheck, UploadCloud, ArrowUpCircle, Github, ExternalLink, ChevronDown, Check, Lock, Search, Link2, KeyRound, ShieldAlert, SlidersHorizontal, ArrowUpDown, CircleDot, MoreHorizontal, RotateCw, Database, Globe, Info, History, PauseCircle, Puzzle, Bot, Loader2, } from 'lucide-react' import PluginIcon from '../shared/PluginIcon' import { useInstanceSettings } from './useInstanceSettings' import { type RangeWarning } from './useRangeBypass' import { deriveCaps } from './pluginCaps' import PluginPoiCategoryList from './PluginPoiCategoryList' import { useTranslation } from '../../i18n' import ConfirmDialog from '../shared/ConfirmDialog' import ToggleSwitch from '../Settings/ToggleSwitch' import CustomSelect from '../shared/CustomSelect' import EmptyState from '../shared/EmptyState' import { Tooltip } from '../shared/Tooltip' import { DialogButton, DialogFooter, DialogHeader, DialogSection, DialogShell, DialogTile, FooterSpacer, NEUTRAL_TINT, fs } from '../shared/DialogShell' import { EditorField, INPUT } from '../shared/dialogParts' import { SettingsCard, SettingRows, SettingRow, SettingsHint, StatusPill, SETTINGS_BUTTON_PRIMARY, SETTINGS_ICON_BUTTON, } from '../Settings/settingsKit' import { POPOVER } from '../Packing/packingPopoverStyles' import { KNOWN_TYPES, PERM_KEYS, blockIsCurrent, dependencyRows, deriveDeps, fingerprint, formatCompactCount, installOffer, isRegistrySourced, parseJson, signatureBlockView, sortLabel, statusLabel, type PluginDep, type PluginRow, type RegistryItem, type SigSubject, type SortKey, type StatusFilter, type T, type TypeFilter, type VersionInfo, type VersionMismatch, } from './plugins/pluginModel' import { usePluginDetail } from './plugins/usePluginDetail' import { usePluginsAdmin } from './plugins/usePluginsAdmin' /** * 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. */ // Runtime health → dot colour on the icon tile. const HEALTH: Record = { active: 'bg-success', starting: 'bg-info animate-pulse', error: 'bg-danger', inactive: 'bg-content-faint/60', disabled: 'bg-warning', incompatible: 'bg-warning', } /** The head band of a dialog that warns before something happens: a faint wash of the warning tone. */ const WARN_TINT = 'color-mix(in srgb, var(--warning) 10%, transparent)' /** The eyebrow over a block, the size the dialogs' sections use. */ const EYEBROW = 'm-0 font-geist font-bold uppercase tracking-[.08em] text-content-faint' /** A capability or dependency chip on an installed row. */ const CHIP = 'inline-flex max-w-full items-center gap-1.5 rounded-full border px-2 py-[2px] font-medium' const CHIP_NEUTRAL = 'border-edge-faint bg-surface-secondary text-content-secondary' const CHIP_NET = 'border-transparent bg-info-soft text-info' const CHIP_WARN = 'border-transparent bg-warning-soft text-warning' /** A small action inside a row or a card: the kit's button, a size down. */ const SMALL_BUTTON = 'inline-flex flex-none items-center justify-center gap-1.5 rounded-[10px] bg-surface-card px-2.5 py-1.5 font-semibold text-content shadow-sm ring-1 ring-edge-faint hover:bg-surface-secondary disabled:cursor-default disabled:opacity-50' const SMALL_PRIMARY = 'inline-flex flex-none items-center justify-center gap-1.5 rounded-[10px] bg-accent px-3 py-1.5 font-semibold text-accent-text hover:opacity-90 disabled:cursor-default disabled:bg-surface-tertiary disabled:text-content-faint' /** A line of state under a row's name: a refused update, a paused one, an error. */ const ROW_NOTE = 'mt-1.5 flex min-w-0 items-center gap-1.5' /** A pill that explains itself on hover. */ function HintPill({ hint, children }: { hint: string; children: ReactNode }) { return ( {children} ) } function ReviewedBadge({ t }: { t: T }) { return } /** Marks a manually-uploaded (sideloaded) plugin: no registry, unsigned, not reviewed. */ function SideloadedBadge({ t }: { t: T }) { return ( }>{t('admin.plugins.sideloaded')} ) } /** * Signed / Unsigned, for registry plugins only (see isRegistrySourced). * * 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 { 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 (
{ if (dragActive) e.preventDefault() }} onDragLeave={onDragLeave} onDrop={onDrop}> {/* Hidden input for the "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)} />} {ignoreTrekRange && ( }>{t('admin.plugins.rangeBypass.pill')} )} }>{t('admin.plugins.runtimeOn')} ) : undefined} action={ready ? ( <> ) : undefined} > {loading ? (
{t('common.loading')}
) : error ? (
{t('admin.plugins.loadError')}
) : !runtimeOn ? ( // Runtime switched off server-side: the notice replaces the whole body.

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

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

) : ( <> {/* Dev-link: register + hot-reload a plugin from a local build dir (dev only). */} {devLink && (
{ e.preventDefault(); void linkLocal() }} className="flex flex-col gap-2 rounded-[12px] border border-edge-faint bg-surface-card p-3.5">
{t('admin.plugins.devLinkTitle')}} htmlFor="plugin-dev-link" className="min-w-[220px] flex-1"> setLinkPath(e.target.value)} spellCheck={false} placeholder={t('admin.plugins.devLinkPathPlaceholder')} className={`${INPUT} font-mono`} />
{t('admin.plugins.devLinkHint')}
)} {/* Toolbar: what to look at on the left, how to narrow and order it on the right. */}
setView('installed')} label={t('admin.plugins.installed')} count={plugins.length} />
} options={[ ['all', t('admin.plugins.allTypes')], ['widget', t('admin.plugins.type.widget')], ['integration', t('admin.plugins.type.integration')], ['page', t('admin.plugins.type.page')], ['trip-page', t('admin.plugins.type.trip-page')], ]} valueLabel={typeFilter === 'all' ? t('admin.plugins.allTypes') : t(`admin.plugins.type.${typeFilter}` as never)} onPick={v => setTypeFilter(v as TypeFilter)} /> {view === 'installed' && ( } options={[ ['all', t('admin.plugins.allStatuses')], ['on', t('admin.plugins.status.active')], ['off', t('admin.plugins.stateOff')], ['update', t('admin.plugins.filterUpdate')], ['err', t('admin.plugins.status.error')], ]} valueLabel={statusLabel(statusFilter, t)} onPick={v => setStatusFilter(v as StatusFilter)} /> )} } options={view === 'discover' ? [['name', t('admin.plugins.sortName')], ['recent', t('admin.plugins.sortRecent')], ['downloads', t('admin.plugins.sortDownloads')]] : [['name', t('admin.plugins.sortName')], ['recent', t('admin.plugins.sortRecent')], ['updates', t('admin.plugins.sortUpdates')]]} valueLabel={sortLabel(sort, t)} onPick={v => setSort(v as SortKey)} />
{view === 'discover' ? ( ) : plugins.length === 0 ? ( {t('admin.plugins.tabDiscover')} )} /> ) : ( <> {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={() => reviewBlock(p)} onErrors={() => openErrors(p.id)} onEgress={() => openEgress(p.id)} onSettings={() => openInstanceSettings(p)} onUninstall={() => askUninstall(p)} /> ))} )} )} )}
{/* Registry detail dialog */} {detailFor && ( setDetailFor(null)} /> )} {bypass.copy && } {errorsFor && setErrorsFor(null)} />} {/* Operator-supplied egress hosts */} {egressFor && ( void saveEgress(hosts)} onClose={() => setEgressFor(null)} t={t} /> )} {/* Instance-wide settings (the admin-owned scope:'instance' fields) */} {settings.form && } setConfirmUninstall(null)} onConfirm={uninstallConfirmed} title={t('admin.plugins.uninstallTitle')} message={t('admin.plugins.uninstallBody')} /> {versionPicker && ( setVersionPicker(null)} /> )} setConfirmDowngrade(null)} onConfirm={downgradeConfirmed} title={t('admin.plugins.downgradeTitle')} message={t('admin.plugins.downgradeBody', { from: confirmDowngrade?.plugin.version ?? '', to: confirmDowngrade?.version ?? '' })} confirmLabel={t('admin.plugins.downgradeConfirm')} /> {signatureBlock && ( setSignatureBlock(null)} /> )} {consentQueue[0] && ( )} {depResolve && ( setDepResolve(null)} /> )}
) } /** 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 ( ) } /** "Nothing matches": a faint search glyph over one quiet line. */ function NoMatch({ children }: { children: ReactNode }) { return (
{children}
) } function SegBtn({ active, onClick, label, count }: { active: boolean; onClick: () => void; label: string; count?: number }) { return ( ) } /** Re-renders while the page moves under an open, portaled menu, so it stays pinned to its trigger. */ function useFollowTrigger(open: boolean) { const [, reanchor] = useState(0) useEffect(() => { if (!open) return const onMove = () => reanchor(n => n + 1) window.addEventListener('scroll', onMove, true) window.addEventListener('resize', onMove) return () => { window.removeEventListener('scroll', onMove, true) window.removeEventListener('resize', onMove) } }, [open]) } function FilterMenu({ id, label, valueLabel, options, onPick, value, menu, setMenu, icon }: { id: string; label: string; valueLabel: string options: Array<[string, string]>; onPick: (v: string) => void; value: string menu: string | null; setMenu: (v: string | null) => void; icon?: ReactNode }) { const open = menu === id const active = value !== options[0]?.[0] const btnRef = useRef(null) // Portaled like the row menu: the card around the toolbar clips what overflows it. useFollowTrigger(open) return (
{open && createPortal(
{options.map(([v, lbl]) => ( ))}
, document.body, )}
) } function InstalledRow({ p, t, busy, menu, setMenu, hasUpdate, latestVer, newerIncompatible, blocked, onToggle, onUpdate, onRestart, onChangeVersion, onResume, onReviewBlock, onErrors, onEgress, onSettings, onUninstall }: { p: PluginRow; t: T; busy: string | null; menu: string | null; setMenu: (v: string | null) => void hasUpdate: boolean; latestVer?: string; newerIncompatible: { version: string; range: string } | null; blocked: boolean onToggle: () => void; onUpdate: () => void; onRestart: () => void; onChangeVersion: () => void; onResume: () => void; onReviewBlock: () => void onErrors: () => void; onEgress: () => void; onSettings: () => void; onUninstall: () => void }) { const caps = deriveCaps(parseJson(p.permissions, []), parseJson<{ widget?: { slot?: string } }>(p.capabilities, {}), t) const deps = deriveDeps(p, t) const menuOpen = menu === `row:${p.id}` const menuBtnRef = useRef(null) const working = busy === p.id const on = p.enabled === 1 && p.status !== 'error' // The menu is portaled to and positioned `fixed` against the ⋯ button, // because the row's ancestor (PageSidebar) is `overflow-hidden` and would clip // an in-flow `absolute` menu — losing Delete on the lower rows of a long list. // Re-anchor while the page moves under it. useFollowTrigger(menuOpen) const hostCount = p.egressHostCount ?? 0 return (
{p.name} {p.version && v{p.version}} {p.reviewed_at && } {p.source_repo === 'local:upload' && } {p.source_repo === 'local:link' && } {/* Registry plugins only — a sideloaded/dev-linked plugin already says something strictly stronger, and stacking "Unsigned" on top of it just dilutes the amber. */} {isRegistrySourced(p.source_repo) && }
{p.description &&

{p.description}

} {/* A refused update leaves a WORKING plugin pinned at its old version — so this is its own row state, not an error state, and it persists until it is resolved rather than dying with the toast that first reported it. */} {blocked && p.updateBlock && (
{t('admin.plugins.updateBlocked', { reason: p.updateBlock.detail ?? p.updateBlock.code })}
)} {/* A newer version this TREK can't run — informational, never a button: the fix is a TREK upgrade, so the row must not nag or offer a doomed install. */} {newerIncompatible && (
{t('admin.plugins.newerNeedsTrek', { version: newerIncompatible.version, range: newerIncompatible.range })}
)} {/* Held: the admin deliberately installed a non-latest version, so updates are paused rather than nagged — resuming is one click, right where the pause shows. */} {p.updateHold && (
{t('admin.plugins.updatesHeld', { version: p.version ?? '' })}
)} {p.status === 'error' && p.last_error ? (
{p.last_error}
) : (caps.length > 0 || p.operatorEgress) && (
{caps.map((c, i) => ( {c.label} ))} {/* This plugin talks to a service only the OPERATOR can name (a self-hosted Gotify/ntfy), so its manifest can't list the host — the admin adds it. Actionable, and warning-toned until at least one host exists, because until then the plugin cannot reach anything and looks silently broken. */} {p.operatorEgress && ( )}
)} {deps.length > 0 && (
{deps.map((d, i) => ( {d.label} ))}
)}
{hasUpdate && ( )} {p.enabled === 1 ? t('admin.plugins.status.active') : t('admin.plugins.stateOff')}
{menuOpen && createPortal(
{p.enabled === 1 && ( } label={t('admin.plugins.restart')} onClick={onRestart} /> )} {/* A plugin gets the item if it declares scope:'instance' fields OR actions — an action-only plugin still needs the dialog to run its buttons. */} {((p.instanceSettingsCount ?? 0) > 0 || (p.instanceActionsCount ?? 0) > 0) && ( } label={t('admin.plugins.instanceSettings')} onClick={onSettings} /> )} } label={t('admin.plugins.viewErrors')} onClick={onErrors} /> {/* Only a plugin that DECLARED operatorEgress gets the item — an admin must never be invited to widen egress for a plugin that didn't ask for it (same rule the row's egress chip follows). */} {p.operatorEgress && ( } label={t('admin.plugins.allowedHosts')} onClick={onEgress} /> )} {/* Registry plugins only — a sideload/dev-link has no registry versions to pick from. */} {isRegistrySourced(p.source_repo) && ( } label={t('admin.plugins.changeVersion')} onClick={onChangeVersion} /> )} {p.source_repo && p.source_repo !== 'local:upload' && p.source_repo !== 'local:link' && ( <> setMenu(null)} className={`${MENU_ITEM} text-content-secondary`} style={fs(12.5, 'body')}> {t('admin.plugins.sourceRepo')} setMenu(null)} className={`${MENU_ITEM} text-content-secondary`} style={fs(12.5, 'body')}> {t('admin.plugins.reportIssue')} )}
} label={t('common.delete')} danger onClick={onUninstall} />
, document.body, )}
) } /** Tallest the ⋯ menu gets (6 items + divider + padding). Drives the flip. */ const ROW_MENU_MAX_H = 260 /** Pin a portaled menu to the right edge of its trigger, flipping up when the bottom is tight. */ function anchorMenu(trigger: HTMLElement | null): CSSProperties { const r = trigger?.getBoundingClientRect() if (!r) return { position: 'fixed', top: 0, right: 0, zIndex: 100 } const spaceBelow = window.innerHeight - r.bottom const openUp = spaceBelow < ROW_MENU_MAX_H && r.top > spaceBelow return { position: 'fixed', right: Math.max(8, window.innerWidth - r.right), ...(openUp ? { bottom: window.innerHeight - r.top + 4 } : { top: r.bottom + 4 }), zIndex: 100, } } /** One entry of a popover menu, in the packing popovers' shape. */ const MENU_ITEM = 'flex w-full items-center gap-2.5 rounded-[9px] px-2.5 py-2 font-medium transition-colors hover:bg-surface-tertiary' function MenuItem({ icon, label, onClick, danger }: { icon: ReactNode; label: string; onClick: () => void; danger?: boolean }) { return ( ) } function Screenshot({ url, className, iconSize = 28 }: { url: string | null; className: string; iconSize?: number }) { const [failed, setFailed] = useState(false) return (
{url && !failed ? ( setFailed(true)} /> ) : (
)}
) } function RegistryGrid({ items, onInstall, onOpenDetail, busy, t, installedIds, filtered, ignoreTrekRange }: { items: RegistryItem[] | null onInstall: (id: string, version?: string, warn?: RangeWarning) => void onOpenDetail: (item: RegistryItem) => void busy: string | null t: T installedIds: Set filtered: boolean ignoreTrekRange: 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, ignoreTrekRange) return (
onOpenDetail(item)} // No press-scale on the card: shrinking it mid-click slides the Install // button out from under the pointer, so the click retargets onto the card // and opens the detail modal instead of installing (#2158). data-no-press onKeyDown={e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onOpenDetail(item) } }} className="group flex min-w-0 cursor-pointer flex-col overflow-hidden rounded-[14px] border border-edge-faint bg-surface-card shadow-sm outline-none transition-shadow hover:shadow-md focus-visible:ring-2 focus-visible:ring-[color:var(--text-primary)]">
{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)} )}
) })}
) } // 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} } /** A white box of short lines between hairlines, for the lists inside a dialog. */ function DialogList({ children }: { children: ReactNode }) { return
    {children}
} /** One line of a DialogList: an icon, what it is, and room for an action on the right. */ function DialogListItem({ icon, children, trailing }: { icon?: ReactNode; children: ReactNode; trailing?: ReactNode }) { return (
  • {icon && {icon}}
    {children}
    {trailing &&
    {trailing}
    }
  • ) } /** A warning line inside a dialog body: a soft amber box with its icon. */ function WarnNote({ icon, children }: { icon: ReactNode; children: ReactNode }) { return (

    {icon}{children}

    ) } /** A raised tile for a dialog's head band. */ function Tile({ children, warn }: { children: ReactNode; warn?: boolean }) { return {children} } /** * The TREK_PLUGINS_IGNORE_TREK_RANGE warning (copy and modes from useRangeBypass). Its own * component rather than ConfirmDialog because the notice mode has no cancel button. */ function RangeBypassDialog({ copy: { title, body, confirm }, onConfirm, onClose, t }: { copy: { title: string; body: string; confirm: boolean }; onConfirm: () => void; onClose: () => void; t: T }) { return ( } tint={WARN_TINT} labelId="plugin-range-bypass-title" title={title} onClose={onClose} />} footer={( {confirm ? ( <> {t('common.cancel')} {t('admin.plugins.installAnyway')} ) : ( {t('common.ok')} )} )} >

    {body}

    ) } /** What the runtime recorded for one plugin, newest as the server sends them. */ function ErrorLogDialog({ id, rows, t, onClose }: { id: string; rows: Array<{ ts: string; level: string; message: string }>; t: T; onClose: () => void }) { return ( } tint={NEUTRAL_TINT} labelId="plugin-error-log-title" title={`${id} — ${t('admin.plugins.errorLog')}`} onClose={onClose} />} > {rows.length === 0 ? ( {t('admin.plugins.noErrors')} ) : (
    {rows.map((r, i) => (
    {r.level} {r.ts} {r.message}
    ))}
    )}
    ) } /** The hosts an operatorEgress plugin may reach, added by the admin. Saving restarts it. */ function EgressDialog({ data, draft, setDraft, saving, error, onSave, onClose, t }: { data: { id: string; supported: boolean; hosts: string[] } draft: string; setDraft: (v: string) => void; saving: boolean; error: string onSave: (hosts: string[]) => void; onClose: () => void; t: T }) { return ( } tint={NEUTRAL_TINT} labelId="plugin-egress-title" title={`${data.id} — ${t('admin.plugins.allowedHosts')}`} onClose={onClose} />} > {!data.supported ? ( {t('admin.plugins.allowedHosts.unsupported')} ) : ( <> {t('admin.plugins.allowedHosts.hint')} {data.hosts.length === 0 ? (

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

    ) : ( {data.hosts.map(h => ( } trailing={( )}> {h} ))} )}
    setDraft(e.target.value)} placeholder="gotify.example.com" aria-label={t('admin.plugins.allowedHosts.add')} className={`${INPUT} flex-1 font-mono`} />
    {error &&

    {error}

    } {t('admin.plugins.allowedHosts.restartNote')} )}
    ) } /** Folds each run of consecutive checkbox fields into one array, keeping the field order. */ function groupToggles(fields: F[]): Array { const out: Array = [] for (const f of fields) { const last = out[out.length - 1] if (f.input_type !== 'checkbox') out.push(f) else if (Array.isArray(last)) last.push(f) else out.push([f]) } return out } /** The admin-owned scope:'instance' fields and actions of one plugin (logic in useInstanceSettings). */ function InstanceSettingsDialog({ settings, t }: { settings: ReturnType; t: T }) { const form = settings.form if (!form) return null const fieldLabel = (f: { key: string; label?: string; required?: boolean }) => ( <>{f.label || f.key}{f.required && *} ) return ( } tint={NEUTRAL_TINT} labelId="plugin-instance-settings-title" title={`${form.id} — ${t('admin.plugins.instanceSettings')}`} onClose={settings.close} />} footer={( {t('common.cancel')} void settings.save()}> {settings.saving && }{t('common.save')} )} > {groupToggles(form.fields).map(f => { if (Array.isArray(f)) { // A run of consecutive checkbox fields shares one box of toggle rows. return ( {f.map(c => ( settings.setValue(c.key, form.values[c.key] !== true)} />} /> ))} ) } const inputId = `plugin-setting-${f.key}` if (f.input_type === 'select' && f.options) { return ( settings.setValue(f.key, String(v))} options={[{ value: '', label: '—' }, ...f.options.map(o => ({ value: o.value, label: o.label }))]} /> ) } return ( settings.setValue(f.key, e.target.value)} className={INPUT} /> ) })} {form.actions.length > 0 && ( {!form.active && {t('admin.plugins.actions.inactive')}} {form.actions.map(a => { const res = settings.actionResult[a.key] return (
    {a.hint && {a.hint}} {res && ( {res.message || (res.ok ? t('common.success') : t('common.error'))} )}
    ) })}
    )} {settings.error &&

    {settings.error}

    }
    ) } function PluginDetailModal({ item, installed, busy, onInstall, onClose, t, locale, ignoreTrekRange, blocked }: { 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, failed } = usePluginDetail(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, ignoreTrekRange) const access = caps.filter(c => !c.net) const linkClass = SETTINGS_ICON_BUTTON return ( } tint={NEUTRAL_TINT} labelId="plugin-detail-title" title={item.name} sub={`${item.author}${item.latest ? ` · v${item.latest}` : ''}`} pills={( <> {item.reviewedAt && }>{t('admin.plugins.reviewed')}} )} onClose={onClose} /> )} footer={( {homepage && ( )} onInstall(item.id, offer.version, offer.warn)} disabled={busy === item.id || installed || offer.blocked} title={installed ? undefined : offer.title}> {busy === item.id ? : } {installed ? t('admin.plugins.installed') : offer.label} )} > {item.screenshotUrl && }

    {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}}
    {!detail && !failed && (
    {t('common.loading')}
    )} {manifest && ( {access.length === 0 && !permissions.includes('db:own') ? ( {t('admin.plugins.noAccess')} ) : ( {access.map((c, i) => ( }>{c.label} ))} {permissions.includes('db:own') && ( }>{t('admin.plugins.perm.db:own')} )} )} )} {/* The tool text a plugin publishes goes straight into every user's assistant context once mcp:tools is granted, and nothing else in this dialog shows it. An admin approving the grant should read the names and descriptions first, not discover them in a chat. */} {manifest && (manifest.capabilities?.mcpTools?.length ?? 0) > 0 && ( {(manifest.capabilities?.mcpTools ?? []).map(tool => ( }> {tool.name} {tool.title &&

    {tool.title}

    } {/* The description is the assistant-facing text: it is what the model reads to decide whether to call the tool, so it is the line an admin most needs to see. Never hidden behind a title. */}

    {tool.description}

    ))}
    {t('admin.plugins.mcpToolsHint')}
    )} {manifest && (egress.length > 0 || manifest.operatorEgress) && (
    {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 && ( {settings.map(s => ( {t(`admin.plugins.scope.${s.scope}` as never)} {s.required && {t('admin.plugins.fieldRequired')}} )}> {s.label} ))} )}
    {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 && ( {detail!.versions!.map(v => ( onInstall(item.id, v.version)} disabled={busy === item.id} className={SMALL_BUTTON} style={fs(11.5, 'body')}> {t('admin.plugins.installCompatible', { version: v.version })} ) ) : ( {t('admin.plugins.versionNeedsTrek', { range: v.trek ?? '' })} )}> ))} )}
    ) } /** A version number with its date and, when the author signed it, a quiet tick. */ function VersionLine({ v, locale }: { v: VersionInfo; locale: string }) { return ( v{v.version} {v.publishedAt && {new Date(v.publishedAt).toLocaleDateString(locale)}} {v.signed && } ) } 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 ( } tint={NEUTRAL_TINT} labelId="plugin-version-picker-title" title={t('admin.plugins.versionPickerTitle', { name: plugin.name })} onClose={onClose} />} > {failed &&

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

    } {!failed && !versions && (
    {t('common.loading')}
    )} {versions?.length === 0 && {t('admin.plugins.noVersions')}} {versions && versions.length > 0 && ( {versions.map(v => { const current = v.version === plugin.version return (
    {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, newKey, retrust, bodyKey } = signatureBlockView(data.code, entry) const offerRetrust = retrust !== null return ( } tint={WARN_TINT} labelId="plugin-signature-title" title={t('admin.plugins.sig.title', { name: data.subject.name })} onClose={onClose} />} footer={( {offerRetrust ? t('admin.plugins.sig.cancel') : t('common.close')} {retrust && ( onRetrust(retrust.version, retrust.newKey)} disabled={busy}> {busy && }{t('admin.plugins.sig.retrustConfirm')} )} )} >

    {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}

    )}
    ) } function KeyRow({ label, value, highlight }: { label: string; value: string; highlight?: boolean }) { return (
    {label} {value}
    ) } 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 ( } tint={WARN_TINT} labelId="plugin-consent-title" title={t('admin.plugins.updateConsentTitle')} sub={t('admin.plugins.updateConsentBody', { name: data.plugin.name, version: data.version })} subWraps onClose={onLater} /> )} footer={( {t('admin.plugins.updateLater')} {t('admin.plugins.updateApprove')} )} > {/* 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 && ( {data.newPermissions.map(perm => ( }> ))} )} {data.newEgress.length > 0 && (
    {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 = dependencyRows(data) return ( } tint={WARN_TINT} labelId="plugin-deps-title" title={t('admin.plugins.dep.resolveTitle')} sub={t('admin.plugins.dep.resolveBody', { name: data.plugin.name })} subWraps onClose={onClose} /> )} footer={( {t('common.cancel')} )} > {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 ( setOpen(o => !o)} aria-expanded={open} className="inline-flex max-w-full items-center gap-1.5 text-start font-bold text-content hover:opacity-80"> {t('admin.plugins.security.title')} )} >
    {t('admin.plugins.reviewedMeaning')}
    {open && (
    {sections.map(([h, b]) => (

    {t(h as never)}

    {t(b as never)}

    ))}
    )}
    ) }