diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md
new file mode 100644
index 000000000..5043b6660
--- /dev/null
+++ b/CODE_OF_CONDUCT.md
@@ -0,0 +1,136 @@
+# Contributor Covenant 3.0 Code of Conduct
+
+## Our Pledge
+
+We pledge to make our community welcoming, safe, and equitable for all.
+
+We are committed to fostering an environment that respects and promotes the dignity, rights, and contributions of all
+individuals, regardless of characteristics including race, ethnicity, caste, color, age, physical characteristics,
+neurodiversity, disability, sex or gender, gender identity or expression, sexual orientation, language, philosophy or
+religion, national or social origin, socio-economic position, level of education, or other status. The same privileges
+of participation are extended to everyone who participates in good faith and in accordance with this Covenant.
+
+## Encouraged Behaviors
+
+While acknowledging differences in social norms, we all strive to meet our community's expectations for positive
+behavior. We also understand that our words and actions may be interpreted differently than we intend based on culture,
+background, or native language.
+
+With these considerations in mind, we agree to behave mindfully toward each other and act in ways that center our shared
+values, including:
+
+1. Respecting the **purpose of our community**, our activities, and our ways of gathering.
+2. Engaging **kindly and honestly** with others.
+3. Respecting **different viewpoints** and experiences.
+4. **Taking responsibility** for our actions and contributions.
+5. Gracefully giving and accepting **constructive feedback**.
+6. Committing to **repairing harm** when it occurs.
+7. Behaving in other ways that promote and sustain the **well-being of our community**.
+
+## Restricted Behaviors
+
+We agree to restrict the following behaviors in our community. Instances, threats, and promotion of these behaviors are
+violations of this Code of Conduct.
+
+1. **Harassment.** Violating explicitly expressed boundaries or engaging in unnecessary personal attention after any
+ clear request to stop.
+2. **Character attacks.** Making insulting, demeaning, or pejorative comments directed at a community member or group of
+ people.
+3. **Stereotyping or discrimination.** Characterizing anyone’s personality or behavior on the basis of immutable
+ identities or traits.
+4. **Sexualization.** Behaving in a way that would generally be considered inappropriately intimate in the context or
+ purpose of the community.
+5. **Violating confidentiality**. Sharing or acting on someone's personal or private information without their
+ permission.
+6. **Endangerment.** Causing, encouraging, or threatening violence or other harm toward any person or group.
+7. Behaving in other ways that **threaten the well-being** of our community.
+
+### Other Restrictions
+
+1. **Misleading identity.** Impersonating someone else for any reason, or pretending to be someone else to evade
+ enforcement actions.
+2. **Failing to credit sources.** Not properly crediting the sources of content you contribute.
+3. **Promotional materials**. Sharing marketing or other commercial content in a way that is outside the norms of the
+ community.
+4. **Irresponsible communication.** Failing to responsibly present content which includes, links or describes any other
+ restricted behaviors.
+
+## Reporting an Issue
+
+Tensions can occur between community members even when they are trying their best to collaborate. Not every conflict
+represents a code of conduct violation, and this Code of Conduct reinforces encouraged behaviors and norms that can help
+avoid conflicts and minimize harm.
+
+When an incident does occur, it is important to report it promptly. To report a possible violation, **send an email to
+report@liketrek.com**.
+
+Community Moderators take reports of violations seriously and will make every effort to respond in a timely manner. They
+will investigate all reports of code of conduct violations, reviewing messages, logs, and recordings, or interviewing
+witnesses and other participants. Community Moderators will keep investigation and enforcement actions as transparent as
+possible while prioritizing safety and confidentiality. In order to honor these values, enforcement actions are carried
+out in private with the involved parties, but communicating to the whole community may be part of a mutually agreed upon
+resolution.
+
+## Addressing and Repairing Harm
+
+****
+
+If an investigation by the Community Moderators finds that this Code of Conduct has been violated, the following
+enforcement ladder may be used to determine how best to repair harm, based on the incident's impact on the individuals
+involved and the community as a whole. Depending on the severity of a violation, lower rungs on the ladder may be
+skipped.
+
+1) Warning
+ 1) Event: A violation involving a single incident or series of incidents.
+ 2) Consequence: A private, written warning from the Community Moderators.
+ 3) Repair: Examples of repair include a private written apology, acknowledgement of responsibility, and seeking
+ clarification on expectations.
+2) Temporarily Limited Activities
+ 1) Event: A repeated incidence of a violation that previously resulted in a warning, or the first incidence of a
+ more serious violation.
+ 2) Consequence: A private, written warning with a time-limited cooldown period designed to underscore the
+ seriousness of the situation and give the community members involved time to process the incident. The cooldown
+ period may be limited to particular communication channels or interactions with particular community members.
+ 3) Repair: Examples of repair may include making an apology, using the cooldown period to reflect on actions and
+ impact, and being thoughtful about re-entering community spaces after the period is over.
+3) Temporary Suspension
+ 1) Event: A pattern of repeated violation which the Community Moderators have tried to address with warnings, or a
+ single serious violation.
+ 2) Consequence: A private written warning with conditions for return from suspension. In general, temporary
+ suspensions give the person being suspended time to reflect upon their behavior and possible corrective actions.
+ 3) Repair: Examples of repair include respecting the spirit of the suspension, meeting the specified conditions for
+ return, and being thoughtful about how to reintegrate with the community when the suspension is lifted.
+4) Permanent Ban
+ 1) Event: A pattern of repeated code of conduct violations that other steps on the ladder have failed to resolve, or
+ a violation so serious that the Community Moderators determine there is no way to keep the community safe with
+ this person as a member.
+ 2) Consequence: Access to all community spaces, tools, and communication channels is removed. In general, permanent
+ bans should be rarely used, should have strong reasoning behind them, and should only be resorted to if working
+ through other remedies has failed to change the behavior.
+ 3) Repair: There is no possible repair in cases of this severity.
+
+This enforcement ladder is intended as a guideline. It does not limit the ability of Community Managers to use their
+discretion and judgment, in keeping with the best interests of our community.
+
+## Scope
+
+This Code of Conduct applies within all community spaces, and also applies when an individual is officially representing
+the community in public or other spaces. Examples of representing our community include using an official email address,
+posting via an official social media account, or acting as an appointed representative at an online or offline event.
+
+## Attribution
+
+This Code of Conduct is adapted from the Contributor Covenant, version 3.0, permanently available
+at [https://www.contributor-covenant.org/version/3/0/](https://www.contributor-covenant.org/version/3/0/).
+
+Contributor Covenant is stewarded by the Organization for Ethical Source and licensed under CC BY-SA 4.0. To view a copy
+of this license,
+visit [https://creativecommons.org/licenses/by-sa/4.0/](https://creativecommons.org/licenses/by-sa/4.0/)
+
+For answers to common questions about Contributor Covenant, see the FAQ
+at [https://www.contributor-covenant.org/faq](https://www.contributor-covenant.org/faq). Translations are provided
+at [https://www.contributor-covenant.org/translations](https://www.contributor-covenant.org/translations). Additional
+enforcement and community guideline resources can be found
+at [https://www.contributor-covenant.org/resources](https://www.contributor-covenant.org/resources). The enforcement
+ladder was inspired by the work of [Mozilla’s code of conduct team](https://github.com/mozilla/inclusion).
+
diff --git a/README.md b/README.md
index 31f8c82d2..92ac2e637 100644
--- a/README.md
+++ b/README.md
@@ -428,6 +428,7 @@ Caddy handles TLS and WebSockets automatically.
| `ADMIN_PASSWORD` | Password for the first admin on initial boot. Pairs with `ADMIN_EMAIL`. | random |
| **Other** | | |
| `DEMO_MODE` | Enable demo mode (hourly data resets) | `false` |
+| `UNSPLASH_ACCESS_KEY` | Optional Unsplash Access Key for trip-cover and place-image search. Without one, TREK uses Unsplash's unauthenticated endpoint, which some datacenter/VPS IPs are blocked from. Get a free key at [unsplash.com/developers](https://unsplash.com/developers). Overrides any per-admin key set in Admin > Settings (where it can also be configured instead). | — |
| `MCP_RATE_LIMIT` | Max MCP API requests per user per minute | `300` |
| `MCP_MAX_SESSION_PER_USER` | Max concurrent MCP sessions per user | `20` |
diff --git a/charts/trek/templates/deployment.yaml b/charts/trek/templates/deployment.yaml
index 09d90dc86..37d52b1c1 100644
--- a/charts/trek/templates/deployment.yaml
+++ b/charts/trek/templates/deployment.yaml
@@ -63,6 +63,12 @@ spec:
name: {{ default (printf "%s-secret" (include "trek.fullname" .)) .Values.existingSecret }}
key: OIDC_CLIENT_SECRET
optional: true
+ - name: UNSPLASH_ACCESS_KEY
+ valueFrom:
+ secretKeyRef:
+ name: {{ default (printf "%s-secret" (include "trek.fullname" .)) .Values.existingSecret }}
+ key: UNSPLASH_ACCESS_KEY
+ optional: true
volumeMounts:
- name: data
mountPath: /app/data
diff --git a/charts/trek/templates/secret.yaml b/charts/trek/templates/secret.yaml
index 20edd114f..08d81cabd 100644
--- a/charts/trek/templates/secret.yaml
+++ b/charts/trek/templates/secret.yaml
@@ -17,6 +17,9 @@ data:
{{- if .Values.secretEnv.OIDC_CLIENT_SECRET }}
OIDC_CLIENT_SECRET: {{ .Values.secretEnv.OIDC_CLIENT_SECRET | b64enc | quote }}
{{- end }}
+ {{- if .Values.secretEnv.UNSPLASH_ACCESS_KEY }}
+ UNSPLASH_ACCESS_KEY: {{ .Values.secretEnv.UNSPLASH_ACCESS_KEY | b64enc | quote }}
+ {{- end }}
{{- end }}
{{- if and (not .Values.existingSecret) (.Values.generateEncryptionKey) }}
@@ -44,4 +47,7 @@ stringData:
{{- if .Values.secretEnv.OIDC_CLIENT_SECRET }}
OIDC_CLIENT_SECRET: {{ .Values.secretEnv.OIDC_CLIENT_SECRET }}
{{- end }}
+ {{- if .Values.secretEnv.UNSPLASH_ACCESS_KEY }}
+ UNSPLASH_ACCESS_KEY: {{ .Values.secretEnv.UNSPLASH_ACCESS_KEY }}
+ {{- end }}
{{- end }}
diff --git a/charts/trek/values.yaml b/charts/trek/values.yaml
index e141aa88f..a0523ce14 100644
--- a/charts/trek/values.yaml
+++ b/charts/trek/values.yaml
@@ -92,6 +92,12 @@ secretEnv:
ADMIN_PASSWORD: ""
# OIDC client secret — set together with env.OIDC_ISSUER and env.OIDC_CLIENT_ID.
OIDC_CLIENT_SECRET: ""
+ # Optional Unsplash Access Key for trip-cover and place-image search.
+ # Without one, TREK uses Unsplash's unauthenticated endpoint, which some
+ # datacenter/VPS IPs (including many Kubernetes clusters) are blocked from.
+ # Get a free key at https://unsplash.com/developers. Can also be set per-admin
+ # in Admin > Settings; this value overrides that. Leave empty to disable.
+ UNSPLASH_ACCESS_KEY: ""
# If true, a random ENCRYPTION_KEY is generated at install and preserved across upgrades
generateEncryptionKey: false
diff --git a/client/src/api/client.ts b/client/src/api/client.ts
index f9a388d66..fd2165ef1 100644
--- a/client/src/api/client.ts
+++ b/client/src/api/client.ts
@@ -461,14 +461,16 @@ export const adminApi = {
addons: () => apiClient.get('/admin/addons').then(r => r.data),
updateAddon: (id: number | string, data: Record) => 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),
+ pluginBrowse: (refresh?: boolean) => apiClient.get('/admin/plugins/registry', { params: refresh ? { refresh: 1 } : undefined }).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),
+ pluginInstall: (id: string, opts?: { version?: string; constraint?: string; withDependencies?: boolean }) =>
+ apiClient.post('/admin/plugins/install', { id, ...opts }).then(r => r.data),
pluginActivate: (id: string, consent?: boolean) => apiClient.post(`/admin/plugins/${id}/activate`, consent ? { consent: true } : {}).then(r => r.data),
pluginDeactivate: (id: string) => apiClient.post(`/admin/plugins/${id}/deactivate`).then(r => r.data),
pluginUpdate: (id: string) => apiClient.post(`/admin/plugins/${id}/update`).then(r => r.data),
pluginUninstall: (id: string, deleteData: boolean) => apiClient.post(`/admin/plugins/${id}/uninstall`, { deleteData }).then(r => r.data),
pluginRescan: () => apiClient.post('/admin/plugins/rescan').then(r => r.data),
+ pluginUpload: (file: File) => { const fd = new FormData(); fd.append('file', file); return apiClient.post('/admin/plugins/upload', fd, { headers: { 'Content-Type': 'multipart/form-data' } }).then(r => r.data) },
pluginErrors: (id: string) => apiClient.get(`/admin/plugins/${id}/errors`).then(r => r.data),
pluginAudit: (id: string) => apiClient.get(`/admin/plugins/${id}/audit`).then(r => r.data),
// Local LLM (Ollama) management for the AI-parsing addon.
@@ -556,9 +558,34 @@ export const addonsApi = {
export const pluginsApi = {
// Active plugins the client renders (page nav entries, dashboard widgets).
active: () => apiClient.get('/plugins').then(r => r.data),
- // Call one of a plugin's own declared routes through the host proxy.
- invoke: (id: string, sub: string, init?: { method?: string; body?: unknown }) =>
- apiClient.request({ url: `/plugins/${id}${sub}`, method: init?.method || 'GET', data: init?.body }).then(r => r.data),
+ // Extra place info contributed by placeDetailProvider plugins (#1429). Fail-safe:
+ // the server skips any slow/failing provider, so this only ever adds rows.
+ placeDetails: (placeId: number) =>
+ apiClient.get(`/place-details/${placeId}`).then(r => r.data as { providers: Array<{ pluginId: string; items: Array<{ label: string; value?: string; url?: string }> }> }),
+ // Validation/warning contributions from warningProvider plugins (#1429). Fail-safe.
+ tripWarnings: (tripId: number) =>
+ apiClient.get(`/trip-warnings/${tripId}`).then(r => r.data as { warnings: Array<{ pluginId: string; level: 'info' | 'warning' | 'error'; message: string; dayId?: number; placeId?: number }> }),
+ // Call one of a plugin's own declared routes through the host proxy. `sub` is
+ // supplied by untrusted plugin code (the trekBridge forwards it verbatim), so it
+ // MUST stay inside the plugin's own /plugins/:id/ namespace. We resolve it with
+ // the URL parser — which normalizes `../`, encoded traversal and backslashes the
+ // same way the browser would before sending — and reject anything that escapes
+ // the prefix or points off-origin. Without this a plugin could send
+ // sub='/../../auth/me' and drive arbitrary authenticated /api routes as the user.
+ invoke: (id: string, sub: string, init?: { method?: string; body?: unknown }) => {
+ const prefix = `/api/plugins/${id}/`
+ let resolved: URL
+ try {
+ resolved = new URL(String(sub).replace(/^\/+/, ''), window.location.origin + prefix)
+ } catch {
+ return Promise.reject(new Error('invalid plugin route'))
+ }
+ if (resolved.origin !== window.location.origin || !resolved.pathname.startsWith(prefix)) {
+ return Promise.reject(new Error('plugin route escapes its namespace'))
+ }
+ const url = resolved.pathname.slice('/api'.length) + resolved.search
+ return apiClient.request({ url, method: init?.method || 'GET', data: init?.body }).then(r => r.data)
+ },
}
export const airtrailApi = {
@@ -675,8 +702,8 @@ export const budgetApi = {
setPayers: (tripId: number | string, id: number, payers: { user_id: number; amount: number }[]) => apiClient.put(`/trips/${tripId}/budget/${id}/payers`, { payers }).then(r => r.data),
perPersonSummary: (tripId: number | string) => apiClient.get(`/trips/${tripId}/budget/summary/per-person`).then(r => r.data),
settlement: (tripId: number | string, base?: string) => apiClient.get(`/trips/${tripId}/budget/settlement`, base ? { params: { base } } : undefined).then(r => r.data),
- createSettlement: (tripId: number | string, data: { from_user_id: number; to_user_id: number; amount: number }) => apiClient.post(`/trips/${tripId}/budget/settlements`, data).then(r => r.data),
- updateSettlement: (tripId: number | string, settlementId: number, data: { from_user_id: number; to_user_id: number; amount: number }) => apiClient.put(`/trips/${tripId}/budget/settlements/${settlementId}`, data).then(r => r.data),
+ createSettlement: (tripId: number | string, data: { from_user_id: number; to_user_id: number; amount: number; currency?: string }) => apiClient.post(`/trips/${tripId}/budget/settlements`, data).then(r => r.data),
+ updateSettlement: (tripId: number | string, settlementId: number, data: { from_user_id: number; to_user_id: number; amount: number; currency?: string }) => apiClient.put(`/trips/${tripId}/budget/settlements/${settlementId}`, data).then(r => r.data),
deleteSettlement: (tripId: number | string, settlementId: number) => apiClient.delete(`/trips/${tripId}/budget/settlements/${settlementId}`).then(r => r.data),
reorderItems: (tripId: number | string, orderedIds: number[]) => apiClient.put(`/trips/${tripId}/budget/reorder/items`, { orderedIds }).then(r => r.data),
reorderCategories: (tripId: number | string, orderedCategories: string[]) => apiClient.put(`/trips/${tripId}/budget/reorder/categories`, { orderedCategories } satisfies BudgetReorderCategoriesRequest).then(r => r.data),
diff --git a/client/src/components/Admin/AdminPluginsPanel.tsx b/client/src/components/Admin/AdminPluginsPanel.tsx
index f030adc83..6c021e331 100644
--- a/client/src/components/Admin/AdminPluginsPanel.tsx
+++ b/client/src/components/Admin/AdminPluginsPanel.tsx
@@ -1,10 +1,10 @@
-import { useEffect, useMemo, useState } from 'react'
+import { useEffect, useMemo, useRef, useState, type DragEvent } from 'react'
import {
- Blocks, AlertTriangle, PackageOpen, RefreshCw, Trash2, Download, Bug, X, ShieldCheck,
+ Blocks, AlertTriangle, PackageOpen, RefreshCw, Trash2, Download, Bug, X, ShieldCheck, UploadCloud,
ArrowUpCircle, Github, ExternalLink, ChevronDown, Check, Lock, Search,
SlidersHorizontal, ArrowUpDown, CircleDot, MoreHorizontal, RotateCw, ArrowRight, Database, Users, LayoutDashboard,
Radio, Luggage, Plane, Globe, Image, CalendarDays, Map, Bell, Cloud, Camera, Compass,
- BookOpen, Wallet, Puzzle,
+ BookOpen, Wallet, Puzzle, MapPin, ListChecks, Pencil, Tag, FileText,
} from 'lucide-react'
import { adminApi } from '../../api/client'
import { useTranslation } from '../../i18n'
@@ -21,6 +21,12 @@ import ToggleSwitch from '../Settings/ToggleSwitch'
* tile; the security section at the bottom explains the model honestly.
*/
+interface PluginDep { id: string; version: string }
+interface VersionMismatch { id: string; wanted: string; installed: string }
+type DependencyStatus = 'ok' | 'addonDisabled' | 'missingPlugin'
+interface PluginDependencies { requiredAddons: string[]; pluginDependencies: PluginDep[] }
+interface DependencyIssues { disabledAddons: string[]; missing: PluginDep[]; versionMismatch: VersionMismatch[] }
+
interface PluginRow {
id: string
name: string
@@ -35,6 +41,9 @@ interface PluginRow {
source_repo: string | null
permissions: string
capabilities: string
+ dependencies?: PluginDependencies
+ dependencyStatus?: DependencyStatus
+ dependencyIssues?: DependencyIssues
}
interface RegistryItem {
id: string
@@ -48,6 +57,8 @@ interface RegistryItem {
minTrekVersion: string | null
reviewedAt: string | null
screenshotUrl: string | null
+ requiredAddons?: string[]
+ pluginDependencies?: PluginDep[]
}
interface RegistryDetail extends RegistryItem {
size: number | null
@@ -58,11 +69,21 @@ interface RegistryDetail extends RegistryItem {
settings: Array<{ key: string; label: string; inputType: string; scope: string; required: boolean }>
license: string | null
icon: string | null
+ requiredAddons?: string[]
+ pluginDependencies?: PluginDep[]
} | null
}
+/** 409 error-body shape from POST /activate when a dependency blocks activation. */
+interface ActivateErr {
+ response?: {
+ status?: number
+ data?: { code?: string; error?: string; newPermissions?: string[]; newEgress?: string[]; addons?: string[]; missing?: PluginDep[]; versionMismatch?: VersionMismatch[] }
+ }
+}
+
type T = (k: string, p?: Record) => string
-type TypeFilter = 'all' | 'widget' | 'page' | 'integration'
+type TypeFilter = 'all' | 'widget' | 'page' | 'integration' | 'trip-page'
type StatusFilter = 'all' | 'on' | 'off' | 'update' | 'err'
type SortKey = 'name' | 'recent' | 'updates'
@@ -83,11 +104,15 @@ const ICON_MAP: Record v.split('-')[0].split('.').map(n => parseInt(n, 10) || 0)
@@ -111,16 +136,50 @@ function deriveCaps(perms: string[], caps: { widget?: { slot?: string } }, t: T)
const out: Cap[] = []
if (perms.includes('db:read:trips')) out.push({ icon: Database, label: t('admin.plugins.cap.readsTrips') })
if (perms.includes('db:read:users')) out.push({ icon: Users, label: t('admin.plugins.cap.readsUsers') })
- if (caps.widget) out.push({ icon: LayoutDashboard, label: t(caps.widget.slot === 'hero' ? 'admin.plugins.cap.heroWidget' : 'admin.plugins.cap.widget') })
+ if (perms.includes('db:write:costs')) out.push({ icon: Wallet, label: t('admin.plugins.cap.writesCosts') })
+ else if (perms.includes('db:read:costs')) out.push({ icon: Wallet, label: t('admin.plugins.cap.readsCosts') })
+ if (perms.includes('db:read:packing')) out.push({ icon: Luggage, label: t('admin.plugins.cap.readsPacking') })
+ if (perms.includes('db:read:files')) out.push({ icon: FileText, label: t('admin.plugins.cap.readsFiles') })
+ if (perms.includes('db:write:places')) out.push({ icon: MapPin, label: t('admin.plugins.cap.writesPlaces') })
+ if (perms.includes('db:write:days')) out.push({ icon: CalendarDays, label: t('admin.plugins.cap.writesDays') })
+ if (perms.includes('db:write:itinerary')) out.push({ icon: ListChecks, label: t('admin.plugins.cap.writesItinerary') })
+ if (perms.includes('db:write:trips')) out.push({ icon: Pencil, label: t('admin.plugins.cap.writesTrips') })
+ if (perms.includes('db:meta')) out.push({ icon: Tag, label: t('admin.plugins.cap.metadata') })
+ if (caps.widget) {
+ const slotKey = caps.widget.slot === 'hero' ? 'admin.plugins.cap.heroWidget'
+ : caps.widget.slot === 'place-detail' ? 'admin.plugins.cap.placeSlot'
+ : 'admin.plugins.cap.widget'
+ out.push({ icon: LayoutDashboard, label: t(slotKey as never) })
+ }
if (perms.some(p => p.startsWith('ws:broadcast'))) out.push({ icon: Radio, label: t('admin.plugins.cap.realtime') })
if (perms.includes('hook:photo-provider')) out.push({ icon: Image, label: t('admin.plugins.cap.photos') })
if (perms.includes('hook:calendar-source')) out.push({ icon: CalendarDays, label: t('admin.plugins.cap.calendar') })
+ if (perms.includes('hook:place-detail-provider')) out.push({ icon: MapPin, label: t('admin.plugins.cap.placeDetails') })
+ if (perms.includes('hook:trip-warning-provider')) out.push({ icon: AlertTriangle, label: t('admin.plugins.cap.warnings') })
+ if (perms.includes('events:subscribe')) out.push({ icon: Radio, label: t('admin.plugins.cap.events') })
for (const h of perms.filter(p => p.startsWith('http:outbound:')).map(p => p.slice('http:outbound:'.length)).filter(Boolean)) {
out.push({ icon: ArrowRight, label: h, net: true })
}
return out
}
+interface DepChip { icon: React.ComponentType<{ size?: number; className?: string }>; label: string; blocked: boolean }
+
+// A plugin's declared dependencies as chips — a required addon (amber when that
+// addon is disabled) or a plugin dependency (amber when missing / version-mismatched).
+function deriveDeps(p: PluginRow, t: T): DepChip[] {
+ const out: DepChip[] = []
+ const issues = p.dependencyIssues
+ for (const a of p.dependencies?.requiredAddons ?? []) {
+ out.push({ icon: Blocks, label: t('admin.plugins.cap.requiresAddon', { addon: a }), blocked: !!issues?.disabledAddons.includes(a) })
+ }
+ for (const d of p.dependencies?.pluginDependencies ?? []) {
+ const blocked = !!(issues?.missing.some(m => m.id === d.id) || issues?.versionMismatch.some(m => m.id === d.id))
+ out.push({ icon: Puzzle, label: t('admin.plugins.cap.dependsOn', { id: d.id, version: d.version }), blocked })
+ }
+ return out
+}
+
function PluginIcon({ name, size = 20, className }: { name: string | null; size?: number; className?: string }) {
const Icon = (name && Object.prototype.hasOwnProperty.call(ICON_MAP, name) && ICON_MAP[name]) || Blocks
return
@@ -135,6 +194,16 @@ function ReviewedBadge({ t, compact }: { t: T; compact?: boolean }) {
)
}
+/** Marks a manually-uploaded (sideloaded) plugin: no registry, unsigned, not reviewed. */
+function SideloadedBadge({ t }: { t: T }) {
+ return (
+
+ {t('admin.plugins.sideloaded')}
+
+ )
+}
+
function TypeBadge({ type, t }: { type: string; t: T }) {
return (
@@ -160,6 +229,8 @@ export default function AdminPluginsPanel() {
// A QUEUE, not one slot: "Update All" can produce several re-consent prompts —
// each must be shown, not silently overwritten by the last one.
const [consentQueue, setConsentQueue] = useState>([])
+ // Open when enabling a plugin is blocked by missing/outdated plugin dependencies.
+ const [depResolve, setDepResolve] = useState<{ plugin: PluginRow; missing: PluginDep[]; versionMismatch: VersionMismatch[] } | null>(null)
const [menu, setMenu] = useState(null)
// Toolbar state.
@@ -168,6 +239,11 @@ export default function AdminPluginsPanel() {
const [statusFilter, setStatusFilter] = useState('all')
const [sort, setSort] = useState('name')
+ // Sideload upload: drag a plugin .zip onto the panel or use the toolbar button.
+ const [dragActive, setDragActive] = useState(false)
+ const uploadInputRef = useRef(null)
+ const dragDepth = useRef(0)
+
const refresh = () => {
adminApi.plugins()
.then((d: { enabled: boolean; plugins: PluginRow[] }) => {
@@ -199,6 +275,43 @@ export default function AdminPluginsPanel() {
setView('discover')
if (!registry) adminApi.pluginBrowse().then(setRegistry).catch(() => setRegistry([]))
}
+ // The rescan/reload button rediscovers locally-installed plugins AND force-pulls
+ // the remote registry (bypassing the 30-min server cache + GitHub's CDN), so a
+ // just-published plugin shows up right away instead of up to ~35 min later.
+ const rescan = () => act('__rescan', async () => {
+ await adminApi.pluginRescan()
+ const items: RegistryItem[] = await adminApi.pluginBrowse(true)
+ setRegistry(items)
+ const map: Record = {}
+ items.forEach((i) => { if (i.latest) map[i.id] = i.latest })
+ setLatest(map)
+ }, t('admin.plugins.rescanned'))
+
+ // Sideload a plugin archive (installs INACTIVE — the admin still consents on activation).
+ const uploadPlugin = async (file: File) => {
+ setBusy('__upload'); setMenu(null)
+ try {
+ const res = await adminApi.pluginUpload(file)
+ setView('installed')
+ toast.success(t('admin.plugins.uploaded', { name: res.id }))
+ } catch (e) {
+ toast.error((e as { response?: { data?: { error?: string } } })?.response?.data?.error || t('admin.plugins.actionError'))
+ } finally {
+ setBusy(null); refresh()
+ }
+ }
+ const pickUpload = () => uploadInputRef.current?.click()
+ const onDragEnter = (e: DragEvent) => {
+ if (!runtimeOn || !Array.from(e.dataTransfer.types).includes('Files')) return
+ e.preventDefault(); dragDepth.current++; setDragActive(true)
+ }
+ const onDragLeave = () => { if (--dragDepth.current <= 0) { dragDepth.current = 0; setDragActive(false) } }
+ const onDrop = (e: DragEvent) => {
+ e.preventDefault(); dragDepth.current = 0; setDragActive(false)
+ if (!runtimeOn) return
+ const f = e.dataTransfer.files?.[0]
+ if (f) void uploadPlugin(f)
+ }
const openErrors = (id: string) => {
setMenu(null)
adminApi.pluginErrors(id)
@@ -211,23 +324,63 @@ export default function AdminPluginsPanel() {
const restart = (id: string) => act(id, async () => { await adminApi.pluginDeactivate(id); await adminApi.pluginActivate(id) }, t('admin.plugins.restarted'))
const installedIds = new Set(plugins.map(p => p.id))
+ // Installed-but-disabled direct deps that enabling `p` will auto-enable first.
+ const autoEnabledDeps = (p: PluginRow) =>
+ (p.dependencies?.pluginDependencies ?? [])
+ .map(d => plugins.find(x => x.id === d.id))
+ .filter((x): x is PluginRow => !!x && x.enabled === 0)
+ .map(x => x.name)
+
+ // Shared handling for a failed activation: route each 409 code to the right fix
+ // (consent dialog, download-dependency dialog, or a clear toast).
+ const onActivateError = (p: PluginRow, e: ActivateErr) => {
+ const d = e?.response?.data
+ if (e?.response?.status === 409 && d?.code === 'CONSENT_REQUIRED') {
+ setConsentQueue(qq => [...qq, { plugin: p, version: latest[p.id] ?? p.version ?? '', newPermissions: d.newPermissions ?? [], newEgress: d.newEgress ?? [] }])
+ } else if (e?.response?.status === 409 && d?.code === 'ADDON_DISABLED') {
+ toast.error(t('admin.plugins.dep.addonDisabledToast', { addons: (d.addons ?? []).join(', ') }))
+ } else if (e?.response?.status === 409 && d?.code === 'DEPENDENCY_MISSING') {
+ setDepResolve({ plugin: p, missing: d.missing ?? [], versionMismatch: d.versionMismatch ?? [] })
+ } else {
+ // DEPENDENCY_CYCLE and everything else surface their server message.
+ toast.error(d?.error || t('admin.plugins.actionError'))
+ }
+ }
+
+ const attemptActivate = (p: PluginRow) => {
+ const cascaded = autoEnabledDeps(p)
+ return adminApi.pluginActivate(p.id)
+ .then(() => {
+ toast.success(t('admin.plugins.activated'))
+ if (cascaded.length) toast.success(t('admin.plugins.dep.autoEnabled', { plugins: cascaded.join(', ') }))
+ setDepResolve(null)
+ })
+ .catch((e: ActivateErr) => onActivateError(p, e))
+ }
+
// Enable/disable a plugin. Re-enabling one whose update widened its permissions
- // must NOT grant them silently — the server answers 409 CONSENT_REQUIRED and we
- // route through the same consent dialog the Update button uses.
+ // must NOT grant them silently (409 CONSENT_REQUIRED → consent dialog); a disabled
+ // required addon or a missing plugin dependency (409 ADDON_DISABLED /
+ // DEPENDENCY_MISSING) routes to the right remedy.
const toggle = (p: PluginRow) => {
if (busy === p.id) return
if (p.enabled === 1) { void act(p.id, () => adminApi.pluginDeactivate(p.id), t('admin.plugins.deactivated')); return }
setBusy(p.id); setMenu(null)
- adminApi.pluginActivate(p.id)
- .then(() => toast.success(t('admin.plugins.activated')))
- .catch((e: { response?: { status?: number; data?: { code?: string; error?: string; newPermissions?: string[]; newEgress?: string[] } } }) => {
- const d = e?.response?.data
- if (e?.response?.status === 409 && d?.code === 'CONSENT_REQUIRED') {
- setConsentQueue(qq => [...qq, { plugin: p, version: latest[p.id] ?? p.version ?? '', newPermissions: d.newPermissions ?? [], newEgress: d.newEgress ?? [] }])
- } else {
- toast.error(d?.error || t('admin.plugins.actionError'))
- }
+ attemptActivate(p).finally(() => { setBusy(null); refresh() })
+ }
+
+ // Download a missing/outdated plugin dependency (latest compatible for its range,
+ // transitively), then retry enabling the plugin that needed it.
+ const resolveDependency = (parent: PluginRow, depId: string, constraint?: string) => {
+ if (busy === parent.id) return
+ setBusy(parent.id)
+ adminApi.pluginInstall(depId, { constraint, withDependencies: true })
+ .then((r: { installed?: string[]; requiredAddons?: string[] }) => {
+ toast.success(t('admin.plugins.dep.downloaded', { id: depId }))
+ if (r?.requiredAddons?.length) toast.error(t('admin.plugins.dep.addonDisabledToast', { addons: r.requiredAddons.join(', ') }))
+ return attemptActivate(parent)
})
+ .catch((e: ActivateErr) => onActivateError(parent, e))
.finally(() => { setBusy(null); refresh() })
}
@@ -285,7 +438,18 @@ export default function AdminPluginsPanel() {
const anyFilter = q.trim() !== '' || typeFilter !== 'all' || statusFilter !== 'all'
return (
-
+
{ if (dragActive) e.preventDefault() }} onDragLeave={onDragLeave} onDrop={onDrop}>
+ {/* Hidden input for the toolbar "Upload plugin" button (drag-drop uses the same handler). */}
+ { const f = e.target.files?.[0]; if (f) void uploadPlugin(f); e.target.value = '' }} />
+ {/* Drag-to-install overlay */}
+ {dragActive && (
+
+
+ {t('admin.plugins.dropToUpload')}
+
+ )}
{/* Click-away layer for any open dropdown (filters or a row's ⋯ menu). */}
{menu &&