mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
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:
@@ -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),
|
||||
|
||||
@@ -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/],
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user