feat(plugins): richer admin panel + registry detail view

Admin list: flush-left header like Addons, type/reviewed badges, runtime
health as a dot on the icon tile (text badge only for problem states),
description + source-repo link on installed cards, manifest icon.

Browse: cards show the plugin screenshot (docs/screenshot.png at the pinned
commit) and open a detail dialog fed by GET /api/admin/plugins/registry/:id —
live-manifest permissions in plain language, egress hosts, setup preview,
repo/homepage links. Manifest fetched server-side through safeDownload at the
reviewed commit, cached per plugin for 30 min and only when a detail opens.

Also fixes the update flow (restart the running child around the install,
keep the admin's enabled intent instead of force-activating disabled
plugins), guards the icon lookup against Object.prototype names, reserves
ids that would shadow static admin routes, stops negative-caching failed
manifest fetches, and makes the version compare prerelease-safe.
This commit is contained in:
Maurice
2026-07-03 23:31:28 +02:00
committed by Maurice
parent 17f3c86967
commit 1127700219
9 changed files with 526 additions and 66 deletions
+1
View File
@@ -462,6 +462,7 @@ export const adminApi = {
updateAddon: (id: number | string, data: Record<string, unknown>) => apiClient.put(`/admin/addons/${id}`, data).then(r => r.data),
plugins: () => apiClient.get('/admin/plugins').then(r => r.data),
pluginBrowse: () => apiClient.get('/admin/plugins/registry').then(r => r.data),
pluginDetail: (id: string) => apiClient.get(`/admin/plugins/registry/${encodeURIComponent(id)}`).then(r => r.data),
pluginInstall: (id: string, version?: string) => apiClient.post('/admin/plugins/install', { id, version }).then(r => r.data),
pluginActivate: (id: string) => apiClient.post(`/admin/plugins/${id}/activate`).then(r => r.data),
pluginDeactivate: (id: string) => apiClient.post(`/admin/plugins/${id}/deactivate`).then(r => r.data),
+309 -65
View File
@@ -1,5 +1,9 @@
import { useEffect, useState } from 'react'
import { Blocks, AlertTriangle, PackageOpen, RefreshCw, Trash2, Download, Bug, X, ShieldCheck, ArrowUpCircle, Puzzle } from 'lucide-react'
import {
Blocks, AlertTriangle, PackageOpen, RefreshCw, Trash2, Download, Bug, X, ShieldCheck,
ArrowUpCircle, Github, ExternalLink, ChevronRight, Check, Luggage, Plane, Globe, Image,
CalendarDays, Map, Bell, Cloud, Camera, Compass, BookOpen, Wallet, Puzzle,
} from 'lucide-react'
import { adminApi } from '../../api/client'
import { useTranslation } from '../../i18n'
import { useToast } from '../shared/Toast'
@@ -8,8 +12,10 @@ import ToggleSwitch from '../Settings/ToggleSwitch'
/**
* Admin → Plugins (#plugins). Separates the admin's ON/OFF intent (the toggle,
* backed by `enabled`) from runtime health (`status`), surfaces per-plugin errors
* and available updates, and hosts the registry browser. Gated by the
* backed by `enabled`) from runtime health (`status`, the coloured dot on the
* icon tile), surfaces per-plugin errors and available updates, and hosts the
* registry browser with a per-plugin detail dialog (screenshot, permissions,
* setup preview — fetched live at the reviewed commit). Gated by the
* runtime-enabled flag.
*/
@@ -18,45 +24,109 @@ interface PluginRow {
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
}
interface RegistryItem {
id: string
name: string
author: string
description: string
repo: string
homepage?: string | null
type: string
latest: string | null
minTrekVersion: string | null
reviewedAt: string | null
screenshotUrl: string | null
}
interface RegistryDetail extends RegistryItem {
size: number | null
publishedAt: string | null
manifest: {
permissions: string[]
egress: string[]
settings: Array<{ key: string; label: string; inputType: string; scope: string; required: boolean }>
license: string | null
icon: string | null
} | null
}
// Runtime health → colour + dot. `enabled` is shown separately by the toggle.
const HEALTH: Record<string, { cls: string; dot: string }> = {
active: { cls: 'text-emerald-600', dot: 'bg-emerald-500' },
starting: { cls: 'text-sky-600', dot: 'bg-sky-500 animate-pulse' },
error: { cls: 'text-rose-600', dot: 'bg-rose-500' },
inactive: { cls: 'text-content-faint', dot: 'bg-content-faint/50' },
disabled: { cls: 'text-amber-600', dot: 'bg-amber-500' },
incompatible: { cls: 'text-orange-600', dot: 'bg-orange-500' },
type T = (k: string, p?: Record<string, unknown>) => string
// Runtime health → dot colour on the icon tile. `enabled` is shown by the toggle.
const HEALTH: Record<string, string> = {
active: 'bg-emerald-500',
starting: 'bg-sky-500 animate-pulse',
error: 'bg-rose-500',
inactive: 'bg-content-faint/50',
disabled: 'bg-amber-500',
incompatible: 'bg-orange-500',
}
// States worth calling out with a text badge (ok states speak through the dot).
const STATUS_BADGE: Record<string, string> = {
starting: 'bg-sky-500/10 text-sky-600',
error: 'bg-rose-500/10 text-rose-600',
disabled: 'bg-amber-500/10 text-amber-600',
incompatible: 'bg-orange-500/10 text-orange-600',
}
// Manifest `icon` is a lucide name; map the common ones, fall back to Blocks.
const ICON_MAP: Record<string, React.ComponentType<{ size?: number; className?: string }>> = {
Luggage, Plane, Globe, Image, CalendarDays, Map, Bell, Cloud, Camera, Compass, BookOpen, Wallet, Puzzle, Blocks,
}
// Known permissions → human-readable i18n key; unknown ones render as raw code.
const PERM_KEYS = [
'db:own', 'db:read:trips', 'db:read:users', 'ws:broadcast:trip', 'ws:broadcast:user',
'hook:photo-provider', 'hook:calendar-source', 'http:outbound',
]
function isNewer(a: string, b: string): boolean {
const pa = a.split('.').map(Number)
const pb = b.split('.').map(Number)
const nums = (v: string) => v.split('-')[0].split('.').map(n => parseInt(n, 10) || 0)
const pa = nums(a)
const pb = nums(b)
for (let i = 0; i < 3; i++) {
const x = pa[i] || 0
const y = pb[i] || 0
if (x !== y) return x > y
}
return false
// Same numeric triple: the stable release is newer than its prerelease.
return !a.includes('-') && b.includes('-')
}
function PluginIcon({ name, size = 18, className }: { name: string | null; size?: number; className?: string }) {
// hasOwnProperty guard: the icon name is remote manifest data, and a plain
// object lookup would otherwise reach Object.prototype ("constructor", …).
const Icon = (name && Object.prototype.hasOwnProperty.call(ICON_MAP, name) && ICON_MAP[name]) || Blocks
return <Icon size={size} className={className} />
}
const KNOWN_TYPES = ['widget', 'page', 'integration']
function TypeBadge({ type, t }: { type: string; t: T }) {
return (
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-[10px] font-semibold bg-accent/10 text-accent">
{KNOWN_TYPES.includes(type) ? t(`admin.plugins.type.${type}` as never) : type}
</span>
)
}
function ReviewedBadge({ t }: { t: T }) {
return (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[10px] font-semibold bg-emerald-500/10 text-emerald-600">
<ShieldCheck size={11} /> {t('admin.plugins.reviewed')}
</span>
)
}
export default function AdminPluginsPanel() {
const { t } = useTranslation()
const { t, locale } = useTranslation()
const toast = useToast()
const [runtimeOn, setRuntimeOn] = useState(false)
const [plugins, setPlugins] = useState<PluginRow[]>([])
@@ -66,6 +136,7 @@ export default function AdminPluginsPanel() {
const [view, setView] = useState<'installed' | 'browse'>('installed')
const [registry, setRegistry] = useState<RegistryItem[] | null>(null)
const [latest, setLatest] = useState<Record<string, string>>({})
const [detailFor, setDetailFor] = useState<RegistryItem | null>(null)
const [errorsFor, setErrorsFor] = useState<{ id: string; rows: Array<{ ts: string; level: string; message: string }> } | null>(null)
const [confirmUninstall, setConfirmUninstall] = useState<PluginRow | null>(null)
@@ -92,9 +163,10 @@ export default function AdminPluginsPanel() {
const act = async (id: string, fn: () => Promise<unknown>, ok: string) => {
setBusy(id)
try { await fn(); toast.success(ok); refresh() }
try { await fn(); toast.success(ok) }
catch (e) { toast.error((e as { response?: { data?: { error?: string } } })?.response?.data?.error || t('admin.plugins.actionError')) }
finally { setBusy(null) }
// Refresh even on failure: a multi-step action may have partially committed.
finally { setBusy(null); refresh() }
}
const openBrowse = () => {
@@ -107,23 +179,22 @@ export default function AdminPluginsPanel() {
.catch(() => setErrorsFor({ id, rows: [] }))
const updateAvailable = (p: PluginRow) => !!(p.version && latest[p.id] && isNewer(latest[p.id], p.version))
const install = (id: string) => act(id, () => adminApi.pluginInstall(id), t('admin.plugins.installed'))
const installedIds = new Set(plugins.map(p => p.id))
return (
<div className="bg-surface-card border border-edge rounded-xl overflow-hidden">
{/* Header */}
<div className="px-6 py-5 border-b border-edge-secondary flex items-center gap-3">
<div className="w-11 h-11 rounded-2xl grid place-items-center bg-accent/10 text-accent">
<Puzzle size={20} />
</div>
<div className="flex-1 min-w-0">
<h2 className="text-base font-semibold text-content">{t('admin.plugins.title')}</h2>
<p className="text-xs text-content-faint mt-0.5">{t('admin.plugins.subtitle')}</p>
{/* Header — flush-left title/subtitle, like the Addons panel */}
<div className="px-6 py-4 border-b border-edge-secondary flex items-center justify-between gap-3">
<div className="min-w-0">
<h2 className="font-semibold text-content">{t('admin.plugins.title')}</h2>
<p className="text-xs mt-1 text-content-muted">{t('admin.plugins.subtitle')}</p>
</div>
{runtimeOn && (
<div className="flex items-center gap-2">
<div className="flex items-center gap-2 shrink-0">
<button onClick={() => act('__rescan', adminApi.pluginRescan, t('admin.plugins.rescanned'))}
className="flex items-center gap-1.5 text-xs font-medium px-3 py-2 rounded-lg border border-edge text-content-muted hover:text-content hover:bg-surface-tertiary transition-colors">
<RefreshCw size={14} /> {t('admin.plugins.rescan')}
<RefreshCw size={14} /> <span className="hidden sm:inline">{t('admin.plugins.rescan')}</span>
</button>
<button onClick={view === 'browse' ? () => setView('installed') : openBrowse}
className="flex items-center gap-1.5 text-xs font-semibold px-3.5 py-2 rounded-lg bg-accent text-accent-text hover:opacity-90 transition-opacity">
@@ -150,8 +221,8 @@ export default function AdminPluginsPanel() {
) : error ? (
<div className="py-10 text-center text-sm text-rose-600">{t('admin.plugins.loadError')}</div>
) : view === 'browse' ? (
<RegistryGrid items={registry} busy={busy} t={t} installedIds={new Set(plugins.map(p => p.id))}
onInstall={(id) => act(id, () => adminApi.pluginInstall(id), t('admin.plugins.installed'))} />
<RegistryGrid items={registry} busy={busy} t={t} installedIds={installedIds}
onInstall={install} onOpenDetail={setDetailFor} />
) : plugins.length === 0 ? (
<div className="py-14 text-center">
<div className="w-14 h-14 rounded-2xl bg-surface-tertiary grid place-items-center mx-auto mb-4">
@@ -167,37 +238,55 @@ export default function AdminPluginsPanel() {
) : (
<div className="space-y-2.5">
{plugins.map(p => {
const health = HEALTH[p.status] || HEALTH.inactive
const hasUpdate = updateAvailable(p)
const statusBadge = STATUS_BADGE[p.status]
return (
<div key={p.id} className="rounded-xl border border-edge bg-surface-secondary/40 p-3.5 flex items-center gap-4">
<div className="w-11 h-11 rounded-xl grid place-items-center bg-surface-card border border-edge shrink-0">
<Blocks size={18} className="text-content-muted" />
<div className="relative shrink-0">
<div className="w-11 h-11 rounded-xl grid place-items-center bg-surface-card border border-edge">
<PluginIcon name={p.icon} className="text-content-muted" />
</div>
<span
className={`absolute -top-1 -right-1 w-3 h-3 rounded-full ring-2 ring-surface-card ${HEALTH[p.status] || HEALTH.inactive}`}
title={t(`admin.plugins.status.${p.status}` as never)}
/>
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-sm font-semibold text-content truncate">{p.name}</span>
{p.version && <span className="text-[11px] text-content-faint font-medium">v{p.version}</span>}
{p.reviewed_at && (
<span className="inline-flex items-center gap-0.5 text-[10px] font-medium text-emerald-600">
<ShieldCheck size={11} /> {t('admin.plugins.reviewed')}
<TypeBadge type={p.type} t={t} />
{p.reviewed_at && <ReviewedBadge t={t} />}
{statusBadge && (
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-[10px] font-semibold ${statusBadge}`}>
{t(`admin.plugins.status.${p.status}` as never)}
</span>
)}
{hasUpdate && (
<button onClick={() => act(p.id, async () => { await adminApi.pluginInstall(p.id); await adminApi.pluginActivate(p.id) }, t('admin.plugins.updated'))}
<button onClick={() => act(p.id, async () => {
// Restart around the install so the running child actually picks up
// the new code, and keep the admin's ON/OFF intent as it was.
const wasEnabled = p.enabled === 1
if (wasEnabled) await adminApi.pluginDeactivate(p.id)
await adminApi.pluginInstall(p.id)
if (wasEnabled) await adminApi.pluginActivate(p.id)
}, t('admin.plugins.updated'))}
className="inline-flex items-center gap-1 text-[10px] font-semibold px-2 py-0.5 rounded-full bg-amber-500/15 text-amber-600 hover:bg-amber-500/25 transition-colors">
<ArrowUpCircle size={11} /> {t('admin.plugins.updateTo', { version: latest[p.id] })}
</button>
)}
</div>
<div className="flex items-center gap-2 mt-1">
<span className={`inline-flex items-center gap-1.5 text-[11px] font-medium ${health.cls}`}>
<span className={`w-1.5 h-1.5 rounded-full ${health.dot}`} />
{t(`admin.plugins.status.${p.status}` as never)}
</span>
<span className="text-[11px] text-content-faint">· {t(`admin.plugins.type.${p.type}` as never)}</span>
</div>
{p.description && (
<p className="text-xs text-content-faint mt-0.5 line-clamp-2">{p.description}</p>
)}
{p.source_repo && (
<a href={`https://github.com/${p.source_repo}`} target="_blank" rel="noreferrer"
title={t('admin.plugins.sourceRepo')}
className="flex items-center gap-1 max-w-fit min-w-0 text-[11px] text-content-faint hover:text-content transition-colors mt-1">
<Github size={11} className="shrink-0" /> <span className="truncate">{p.source_repo}</span>
</a>
)}
{p.status === 'error' && p.last_error && (
<p className="text-[11px] text-rose-500/90 mt-1 truncate">{p.last_error}</p>
)}
@@ -230,6 +319,14 @@ export default function AdminPluginsPanel() {
<p className="text-xs text-content-faint">{t('admin.plugins.trustNote')}</p>
</div>
{/* Registry detail dialog */}
{detailFor && (
<PluginDetailModal item={detailFor} t={t} locale={locale} busy={busy}
installed={installedIds.has(detailFor.id)}
onInstall={install}
onClose={() => setDetailFor(null)} />
)}
{/* Error-log modal */}
{errorsFor && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4" onClick={() => setErrorsFor(null)}>
@@ -278,11 +375,27 @@ function IconBtn({ children, title, onClick, disabled, danger }: {
)
}
function RegistryGrid({ items, onInstall, busy, t, installedIds }: {
function Screenshot({ url, className, iconSize = 28 }: { url: string | null; className: string; iconSize?: number }) {
const [failed, setFailed] = useState(false)
return (
<div className={`bg-surface-tertiary overflow-hidden ${className}`}>
{url && !failed ? (
<img src={url} alt="" loading="lazy" className="w-full h-full object-cover" onError={() => setFailed(true)} />
) : (
<div className="w-full h-full grid place-items-center">
<Blocks size={iconSize} className="text-content-faint/50" />
</div>
)}
</div>
)
}
function RegistryGrid({ items, onInstall, onOpenDetail, busy, t, installedIds }: {
items: RegistryItem[] | null
onInstall: (id: string) => void
onOpenDetail: (item: RegistryItem) => void
busy: string | null
t: (k: string, p?: Record<string, unknown>) => string
t: T
installedIds: Set<string>
}) {
if (!items) return <div className="py-10 text-center text-sm text-content-faint">{t('common.loading')}</div>
@@ -292,32 +405,163 @@ function RegistryGrid({ items, onInstall, busy, t, installedIds }: {
{items.map(item => {
const installed = installedIds.has(item.id)
return (
<div key={item.id} className="border border-edge rounded-xl p-4 bg-surface-secondary/40 flex flex-col">
<div className="flex items-center gap-2">
<div className="w-9 h-9 rounded-lg grid place-items-center bg-surface-card border border-edge shrink-0">
<Blocks size={16} className="text-content-muted" />
<div key={item.id} role="button" tabIndex={0} onClick={() => onOpenDetail(item)}
onKeyDown={e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onOpenDetail(item) } }}
className="border border-edge rounded-xl bg-surface-secondary/40 overflow-hidden flex flex-col cursor-pointer hover:border-accent/50 transition-colors">
<Screenshot url={item.screenshotUrl} className="aspect-video" />
<div className="p-4 flex flex-col flex-1">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-sm font-semibold text-content truncate">{item.name}</span>
{item.latest && <span className="text-[10px] text-content-faint">v{item.latest}</span>}
<TypeBadge type={item.type} t={t} />
{item.reviewedAt && <ReviewedBadge t={t} />}
</div>
<div className="min-w-0">
<div className="flex items-center gap-1.5">
<span className="text-sm font-semibold text-content truncate">{item.name}</span>
{item.latest && <span className="text-[10px] text-content-faint">v{item.latest}</span>}
</div>
<span className="text-[11px] text-content-faint">{item.author} · {t(`admin.plugins.type.${item.type}` as never)}</span>
<span className="text-[11px] text-content-faint mt-0.5">{item.author}</span>
<p className="text-xs text-content-faint mt-2 line-clamp-2 flex-1">{item.description}</p>
<div className="flex items-center justify-between mt-3">
<span className="inline-flex items-center gap-0.5 text-[11px] font-medium text-accent">
{t('admin.plugins.details')} <ChevronRight size={12} />
</span>
<button onClick={e => { e.stopPropagation(); onInstall(item.id) }} disabled={busy === item.id || installed}
className="text-xs font-semibold px-3.5 py-1.5 rounded-lg bg-accent text-accent-text disabled:opacity-50 disabled:bg-surface-tertiary disabled:text-content-faint">
{installed ? t('admin.plugins.installed') : t('admin.plugins.install')}
</button>
</div>
</div>
<p className="text-xs text-content-faint mt-2.5 line-clamp-2 flex-1">{item.description}</p>
<div className="flex items-center justify-between mt-3">
{item.reviewedAt
? <span className="inline-flex items-center gap-1 text-[10px] font-medium text-emerald-600"><ShieldCheck size={12} /> {t('admin.plugins.reviewed')}</span>
: <span className="text-[10px] text-content-faint">{t('admin.plugins.unreviewed')}</span>}
<button onClick={() => onInstall(item.id)} disabled={busy === item.id || installed}
className="text-xs font-semibold px-3.5 py-1.5 rounded-lg bg-accent text-accent-text disabled:opacity-50 disabled:bg-surface-tertiary disabled:text-content-faint">
{installed ? t('admin.plugins.installed') : t('admin.plugins.install')}
</button>
</div>
</div>
)
})}
</div>
)
}
function PluginDetailModal({ item, installed, busy, onInstall, onClose, t, locale }: {
item: RegistryItem
installed: boolean
busy: string | null
onInstall: (id: string) => void
onClose: () => void
t: T
locale: string
}) {
const [detail, setDetail] = useState<RegistryDetail | null>(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 repoUrl = `https://github.com/${item.repo}`
// Registry data is curated but still remote: only ever link plain http(s) URLs.
const homepage = item.homepage && /^https?:\/\//i.test(item.homepage) && item.homepage !== repoUrl ? item.homepage : null
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4" onClick={onClose}>
<div className="bg-surface-card border border-edge rounded-xl w-full max-w-2xl max-h-[85vh] flex flex-col shadow-xl overflow-hidden" onClick={e => e.stopPropagation()}>
<div className="relative shrink-0">
<Screenshot url={item.screenshotUrl} className="aspect-video max-h-64 w-full" iconSize={36} />
<button onClick={onClose}
className="absolute top-3 right-3 w-8 h-8 grid place-items-center rounded-lg bg-black/40 text-white hover:bg-black/60 transition-colors">
<X size={16} />
</button>
</div>
<div className="p-5 overflow-y-auto">
<div className="flex items-center gap-2 flex-wrap">
<h3 className="text-base font-semibold text-content">{item.name}</h3>
{item.latest && <span className="text-[11px] text-content-faint font-medium">v{item.latest}</span>}
<TypeBadge type={item.type} t={t} />
{item.reviewedAt && <ReviewedBadge t={t} />}
</div>
<p className="text-xs text-content-faint mt-0.5">{item.author}</p>
<p className="text-sm text-content-muted mt-3">{item.description}</p>
{failed && <p className="text-xs text-rose-500 mt-3">{t('admin.plugins.detailError')}</p>}
{/* Permissions */}
{manifest && (
<div className="mt-5">
<h4 className="text-xs font-semibold uppercase tracking-wider text-content-muted">{t('admin.plugins.permissionsTitle')}</h4>
{manifest.permissions.length === 0 ? (
<p className="text-xs text-content-faint mt-2">{t('admin.plugins.noPermissions')}</p>
) : (
<ul className="mt-2 space-y-1.5">
{manifest.permissions.map(perm => (
<li key={perm} className="flex items-start gap-2 text-xs text-content-muted">
<Check size={13} className="text-accent mt-0.5 shrink-0" />
{PERM_KEYS.includes(perm)
? <span>{t(`admin.plugins.perm.${perm}` as never)}</span>
: <code className="font-mono text-[11px] bg-surface-tertiary px-1.5 py-0.5 rounded">{perm}</code>}
</li>
))}
</ul>
)}
{manifest.egress.length > 0 && (
<p className="text-[11px] text-content-faint mt-2">
{t('admin.plugins.egressNote', { hosts: manifest.egress.join(', ') })}
</p>
)}
</div>
)}
{/* Setup preview */}
{manifest && (
<div className="mt-5">
<h4 className="text-xs font-semibold uppercase tracking-wider text-content-muted">{t('admin.plugins.setupTitle')}</h4>
{manifest.settings.length === 0 ? (
<p className="text-xs text-content-faint mt-2">{t('admin.plugins.noSetup')}</p>
) : (
<ul className="mt-2 space-y-1.5">
{manifest.settings.map(s => (
<li key={s.key} className="flex items-center gap-2 text-xs text-content-muted flex-wrap">
<span className="font-medium">{s.label}</span>
<span className="text-[10px] px-1.5 py-0.5 rounded-full bg-surface-tertiary text-content-faint">
{t(`admin.plugins.scope.${s.scope}` as never)}
</span>
{s.required && (
<span className="text-[10px] px-1.5 py-0.5 rounded-full bg-amber-500/10 text-amber-600">
{t('admin.plugins.fieldRequired')}
</span>
)}
</li>
))}
</ul>
)}
</div>
)}
{/* Links */}
<div className="mt-5 flex items-center gap-2 flex-wrap">
<a href={repoUrl} target="_blank" rel="noreferrer"
className="inline-flex items-center gap-1.5 text-xs font-medium px-3 py-1.5 rounded-lg border border-edge text-content-muted hover:text-content hover:bg-surface-tertiary transition-colors">
<Github size={13} /> {t('admin.plugins.sourceRepo')}
</a>
{homepage && (
<a href={homepage} target="_blank" rel="noreferrer"
className="inline-flex items-center gap-1.5 text-xs font-medium px-3 py-1.5 rounded-lg border border-edge text-content-muted hover:text-content hover:bg-surface-tertiary transition-colors">
<ExternalLink size={13} /> {t('admin.plugins.homepage')}
</a>
)}
</div>
</div>
<div className="px-5 py-3.5 border-t border-edge-secondary bg-surface-secondary flex items-center justify-between gap-3 shrink-0">
<div className="text-[11px] text-content-faint flex items-center gap-3 flex-wrap">
{item.minTrekVersion && <span>{t('admin.plugins.requiresTrek', { version: item.minTrekVersion })}</span>}
{item.reviewedAt && <span>{t('admin.plugins.reviewedOn', { date: new Date(item.reviewedAt).toLocaleDateString(locale) })}</span>}
</div>
<button onClick={() => onInstall(item.id)} disabled={busy === item.id || installed}
className="text-xs font-semibold px-4 py-2 rounded-lg bg-accent text-accent-text disabled:opacity-50 disabled:bg-surface-tertiary disabled:text-content-faint shrink-0">
{installed ? t('admin.plugins.installed') : t('admin.plugins.install')}
</button>
</div>
</div>
</div>
)
}
@@ -51,6 +51,9 @@ export interface PluginManifest {
}
const ID_RE = /^[a-z][a-z0-9-]{2,39}$/;
// Static path segments under /api/admin/plugins — a plugin id must never shadow them
// (id "registry" would collide with GET registry/:id vs :id/errors routing).
const RESERVED_IDS = new Set(['registry', 'install', 'rescan']);
const SEMVER_RE = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/;
const TYPES = new Set(['integration', 'page', 'widget']);
@@ -62,6 +65,7 @@ export function parseManifest(raw: unknown): PluginManifest {
const id = str(m.id, 'id');
if (!ID_RE.test(id)) throw new ManifestError(`invalid id "${id}" (lowercase slug, 3–40 chars)`);
if (RESERVED_IDS.has(id)) throw new ManifestError(`reserved id "${id}"`);
const version = str(m.version, 'version');
if (!SEMVER_RE.test(version)) throw new ManifestError(`invalid version "${version}"`);
const type = str(m.type, 'type');
@@ -33,6 +33,15 @@ export class PluginsController {
return this.registry.browse();
}
@Get('registry/:id')
async registryDetail(@Param('id') id: string) {
try {
return await this.registry.detail(id);
} catch (e) {
throw new HttpException({ error: e instanceof Error ? e.message : 'not found' }, 404);
}
}
@Post('install')
@HttpCode(200)
async install(@Body() body: { id?: string; version?: string }) {
@@ -22,6 +22,7 @@ const REGISTRY_URL =
process.env.TREK_PLUGIN_REGISTRY_URL ||
'https://raw.githubusercontent.com/mauriceboe/TREK-Plugins/main/dist/index.json';
const CACHE_TTL = 30 * 60 * 1000;
const MANIFEST_MAX_BYTES = 256 * 1024;
interface RegistryVersion {
version: string;
@@ -34,6 +35,7 @@ interface RegistryVersion {
size?: number;
apiVersion?: number;
nativeModules?: boolean;
publishedAt?: string;
}
export interface RegistryEntry {
id: string;
@@ -53,11 +55,22 @@ interface Registry {
plugins: RegistryEntry[];
}
/** Anzeige-only preview of a plugin's live manifest (fetched at the reviewed commit). */
export interface ManifestPreview {
permissions: string[];
egress: string[];
settings: Array<{ key: string; label: string; inputType: string; scope: string; required: boolean }>;
license: string | null;
icon: string | null;
}
let _cache: { data: Registry; expiresAt: number } | null = null;
const _detailCache = new Map<string, { data: unknown; expiresAt: number }>();
/** Test hook: drop the in-memory registry cache. */
export function __clearRegistryCacheForTests(): void {
_cache = null;
_detailCache.clear();
}
export class RegistryError extends Error {}
@@ -80,7 +93,7 @@ export class PluginRegistryService {
}
/** The browse list the admin UI renders (metadata only, no code). */
async browse(): Promise<Array<Omit<RegistryEntry, 'versions'> & { latest: string | null; minTrekVersion: string | null }>> {
async browse(): Promise<Array<Omit<RegistryEntry, 'versions'> & { latest: string | null; minTrekVersion: string | null; screenshotUrl: string | null }>> {
const reg = await this.fetchRegistry();
return reg.plugins.map((p) => {
const latest = p.versions[0] ?? null;
@@ -88,10 +101,53 @@ export class PluginRegistryService {
id: p.id, name: p.name, author: p.author, description: p.description, repo: p.repo,
homepage: p.homepage, tags: p.tags, type: p.type, reviewedAt: p.reviewedAt ?? null,
latest: latest?.version ?? null, minTrekVersion: latest?.minTrekVersion ?? null,
screenshotUrl: latest ? rawFileUrl(p.repo, latest.commitSha, 'docs/screenshot.png') : null,
};
});
}
/**
* Everything the browse detail view shows for one plugin: the registry entry
* plus a preview of its live manifest (permissions, egress, settings) fetched
* from the author repo at the pinned/reviewed commit. Display-only — the
* install pipeline re-validates the manifest inside the downloaded artifact.
* Cached per plugin (30 min) and only fetched when a detail view opens, never
* for the whole grid (the HACS rate-limit lesson).
*/
async detail(id: string): Promise<Record<string, unknown>> {
const hit = _detailCache.get(id);
if (hit && Date.now() < hit.expiresAt) return hit.data as Record<string, unknown>;
const reg = await this.fetchRegistry();
const entry = reg.plugins.find((p) => p.id === id);
if (!entry) throw new RegistryError(`plugin ${id} not in registry`);
const latest = entry.versions[0] ?? null;
let manifest: ManifestPreview | null = null;
if (latest) {
try {
const { bytes } = await safeDownload(rawFileUrl(entry.repo, latest.commitSha, 'trek-plugin.json'), MANIFEST_MAX_BYTES);
manifest = previewManifest(JSON.parse(bytes.toString('utf8')));
} catch {
// Soft-fail: the detail view still renders from registry metadata alone.
}
}
const data = {
id: entry.id, name: entry.name, author: entry.author, description: entry.description,
repo: entry.repo, homepage: entry.homepage ?? null, tags: entry.tags ?? [], type: entry.type,
reviewedAt: entry.reviewedAt ?? null,
latest: latest?.version ?? null, minTrekVersion: latest?.minTrekVersion ?? null,
size: latest?.size ?? null, publishedAt: latest?.publishedAt ?? null,
screenshotUrl: latest ? rawFileUrl(entry.repo, latest.commitSha, 'docs/screenshot.png') : null,
manifest,
};
// Don't negative-cache a transiently failed manifest fetch — the next
// detail open should retry instead of hiding the preview for 30 minutes.
if (manifest || !latest) _detailCache.set(id, { data, expiresAt: Date.now() + CACHE_TTL });
return data;
}
/** Install a pinned version from the registry. Returns the installed plugin id. */
async install(id: string, version?: string): Promise<{ id: string; version: string }> {
const reg = await this.fetchRegistry();
@@ -134,6 +190,40 @@ export class PluginRegistryService {
}
}
function rawFileUrl(repo: string, commitSha: string, file: string): string {
return `https://raw.githubusercontent.com/${repo}/${commitSha}/${file}`;
}
/**
* Tolerant projection of a live manifest for display. Unknown shapes degrade to
* empty lists instead of throwing — a future manifest field must never break
* browsing (strict validation happens against the downloaded artifact instead).
*/
function previewManifest(raw: unknown): ManifestPreview {
const m = (raw && typeof raw === 'object' ? raw : {}) as Record<string, unknown>;
const strings = (v: unknown): string[] =>
Array.isArray(v) ? v.filter((x): x is string => typeof x === 'string') : [];
const settings = Array.isArray(m.settings)
? m.settings
.filter((s): s is Record<string, unknown> => !!s && typeof s === 'object')
.map((s) => ({
key: typeof s.key === 'string' ? s.key : '',
label: typeof s.label === 'string' ? s.label : typeof s.key === 'string' ? s.key : '',
inputType: typeof s.input_type === 'string' ? s.input_type : 'text',
scope: s.scope === 'user' ? 'user' : 'instance',
required: s.required === true,
}))
.filter((s) => s.key)
: [];
return {
permissions: strings(m.permissions),
egress: strings(m.egress),
settings,
license: typeof m.license === 'string' ? m.license : null,
icon: typeof m.icon === 'string' ? m.icon : null,
};
}
/** The extracted plugin root: staging itself, or its single wrapper subdir (codeload archives wrap in {repo}-{sha}/). */
function locateManifestDir(staging: string): string | null {
if (fs.existsSync(path.join(staging, 'trek-plugin.json'))) return staging;
@@ -100,6 +100,73 @@ describe('PluginRegistryService', () => {
]);
});
it('browse exposes a screenshot url pinned at the reviewed commit', async () => {
const list = await svc.browse();
expect(list[0].screenshotUrl).toBe(`https://raw.githubusercontent.com/acme/trek-flight/${'a'.repeat(40)}/docs/screenshot.png`);
});
it('detail merges registry metadata with a live manifest preview', async () => {
safeDownload.mockResolvedValue({
bytes: Buffer.from(JSON.stringify({
id: 'flight-tracker', permissions: ['db:read:trips', 'http:outbound'], egress: ['api.example.com'],
settings: [{ key: 'api_key', label: 'API Key', input_type: 'password', scope: 'instance', required: true, secret: true }],
license: 'MIT', icon: 'Plane',
})),
sha256: 'unused',
});
const d = await svc.detail('flight-tracker');
expect(safeDownload).toHaveBeenCalledWith(
`https://raw.githubusercontent.com/acme/trek-flight/${'a'.repeat(40)}/trek-plugin.json`,
expect.any(Number),
);
expect(d).toMatchObject({
id: 'flight-tracker', repo: 'acme/trek-flight', latest: '1.0.0', reviewedAt: '2026-06-20',
screenshotUrl: `https://raw.githubusercontent.com/acme/trek-flight/${'a'.repeat(40)}/docs/screenshot.png`,
manifest: {
permissions: ['db:read:trips', 'http:outbound'],
egress: ['api.example.com'],
settings: [{ key: 'api_key', label: 'API Key', inputType: 'password', scope: 'instance', required: true }],
license: 'MIT', icon: 'Plane',
},
});
});
it('detail soft-fails the manifest fetch (registry metadata still renders)', async () => {
safeDownload.mockRejectedValue(new Error('offline'));
const d = await svc.detail('flight-tracker');
expect(d).toMatchObject({ id: 'flight-tracker', manifest: null });
});
it('detail tolerates a malformed manifest shape', async () => {
safeDownload.mockResolvedValue({
bytes: Buffer.from(JSON.stringify({ permissions: 'not-an-array', settings: [{ label: 'no key' }], egress: [7, 'ok.host'] })),
sha256: 'unused',
});
const d = (await svc.detail('flight-tracker')) as { manifest: { permissions: string[]; egress: string[]; settings: unknown[] } };
expect(d.manifest).toMatchObject({ permissions: [], egress: ['ok.host'], settings: [] });
});
it('detail is cached per plugin (one manifest fetch across calls)', async () => {
safeDownload.mockResolvedValue({ bytes: Buffer.from('{}'), sha256: 'unused' });
await svc.detail('flight-tracker');
await svc.detail('flight-tracker');
expect(safeDownload).toHaveBeenCalledTimes(1);
});
it('detail does not negative-cache a failed manifest fetch (next open retries)', async () => {
safeDownload.mockRejectedValueOnce(new Error('github hiccup'));
const first = (await svc.detail('flight-tracker')) as { manifest: unknown };
expect(first.manifest).toBeNull();
safeDownload.mockResolvedValue({ bytes: Buffer.from(JSON.stringify({ permissions: ['db:own'] })), sha256: 'unused' });
const second = (await svc.detail('flight-tracker')) as { manifest: { permissions: string[] } };
expect(second.manifest).toMatchObject({ permissions: ['db:own'] });
expect(safeDownload).toHaveBeenCalledTimes(2);
});
it('detail rejects an unknown plugin id', async () => {
await expect(svc.detail('ghost')).rejects.toThrow(RegistryError);
});
it('fetchRegistry soft-fails to an empty registry on a cold cache', async () => {
__clearRegistryCacheForTests();
vi.stubGlobal('fetch', vi.fn(async () => { throw new Error('offline'); }));
@@ -34,6 +34,7 @@ describe('parseManifest', () => {
it.each([
['Bad-Id', { ...base, id: 'Bad-Id' }, /invalid id/],
['reserved id', { ...base, id: 'registry' }, /reserved id/],
['bad version', { ...base, version: '1.x' }, /invalid version/],
['bad type', { ...base, type: 'thing' }, /invalid type/],
['native', { ...base, nativeModules: true }, /native modules/],
+22
View File
@@ -200,6 +200,28 @@ const admin: TranslationStrings = {
'admin.plugins.status.disabled': 'Deaktiviert',
'admin.plugins.status.error': 'Fehler',
'admin.plugins.status.incompatible': 'Inkompatibel',
'admin.plugins.details': 'Details',
'admin.plugins.detailError': 'Plugin-Details konnten nicht geladen werden.',
'admin.plugins.permissionsTitle': 'Berechtigungen',
'admin.plugins.noPermissions': 'Dieses Plugin benötigt keine besonderen Berechtigungen.',
'admin.plugins.egressNote': 'Darf verbinden zu: {hosts}',
'admin.plugins.setupTitle': 'Einrichtung',
'admin.plugins.noSetup': 'Funktioniert sofort — nichts zu konfigurieren.',
'admin.plugins.scope.instance': 'Instanzweit',
'admin.plugins.scope.user': 'Pro Nutzer',
'admin.plugins.fieldRequired': 'Erforderlich',
'admin.plugins.sourceRepo': 'Quell-Repository',
'admin.plugins.homepage': 'Homepage',
'admin.plugins.requiresTrek': 'Benötigt TREK {version}+',
'admin.plugins.reviewedOn': 'Geprüft am {date}',
'admin.plugins.perm.db:own': 'Eigene Daten in einer isolierten Datenbank speichern',
'admin.plugins.perm.db:read:trips': 'Reisen lesen, auf die der handelnde Nutzer Zugriff hat',
'admin.plugins.perm.db:read:users': 'Grundlegende Profilinfos lesen (Name, Avatar — nie Zugangsdaten)',
'admin.plugins.perm.ws:broadcast:trip': 'Echtzeit-Updates an Reisemitglieder senden',
'admin.plugins.perm.ws:broadcast:user': 'Echtzeit-Updates an einzelne Nutzer senden',
'admin.plugins.perm.hook:photo-provider': 'Fotos für Erinnerungen bereitstellen',
'admin.plugins.perm.hook:calendar-source': 'Termine für den Kalender bereitstellen',
'admin.plugins.perm.http:outbound': 'Ausgehende Anfragen an deklarierte Hosts stellen',
'admin.addons.title': 'Addons',
'admin.addons.subtitle': 'Aktiviere oder deaktiviere Funktionen, um TREK nach deinen Wünschen anzupassen.',
'admin.addons.catalog.packing.name': 'Listen',
+22
View File
@@ -252,6 +252,28 @@ const admin: TranslationStrings = {
'admin.plugins.status.disabled': 'Disabled',
'admin.plugins.status.error': 'Error',
'admin.plugins.status.incompatible': 'Incompatible',
'admin.plugins.details': 'Details',
'admin.plugins.detailError': 'Could not load plugin details.',
'admin.plugins.permissionsTitle': 'Permissions',
'admin.plugins.noPermissions': 'This plugin requests no special permissions.',
'admin.plugins.egressNote': 'May connect to: {hosts}',
'admin.plugins.setupTitle': 'Setup',
'admin.plugins.noSetup': 'Works out of the box — nothing to configure.',
'admin.plugins.scope.instance': 'Instance-wide',
'admin.plugins.scope.user': 'Per user',
'admin.plugins.fieldRequired': 'Required',
'admin.plugins.sourceRepo': 'Source repository',
'admin.plugins.homepage': 'Homepage',
'admin.plugins.requiresTrek': 'Requires TREK {version}+',
'admin.plugins.reviewedOn': 'Reviewed {date}',
'admin.plugins.perm.db:own': 'Store its own data in an isolated database',
'admin.plugins.perm.db:read:trips': 'Read trips the acting user can access',
'admin.plugins.perm.db:read:users': 'Read basic profile info (name, avatar — never credentials)',
'admin.plugins.perm.ws:broadcast:trip': 'Send real-time updates to trip members',
'admin.plugins.perm.ws:broadcast:user': 'Send real-time updates to individual users',
'admin.plugins.perm.hook:photo-provider': 'Provide photos to Memories',
'admin.plugins.perm.hook:calendar-source': 'Provide events to the calendar',
'admin.plugins.perm.http:outbound': 'Make outbound requests to its declared hosts',
'admin.addons.title': 'Addons',
'admin.addons.subtitle': 'Enable or disable features to customize your TREK experience.',
'admin.addons.catalog.packing.name': 'Lists',