/** * Plugin Manager Component * * Displays list of configured plugins with enable/disable controls. * Extended with marketplace features: source badges, update checking, * update/uninstall for marketplace-installed plugins. */ import { Badge, Button, Checkbox, Switch, Toast } from "@cloudflare/kumo"; import { plural } from "@lingui/core/macro"; import { useLingui } from "@lingui/react/macro"; import { PuzzlePiece, Gear, FileText, SquaresFour, WebhooksLogo, CaretDown, ArrowsClockwise, Storefront, Trash, ShieldCheck, } from "@phosphor-icons/react"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { Link } from "@tanstack/react-router"; import * as React from "react"; import { fetchPlugins, enablePlugin, disablePlugin, type PluginInfo, type AdminManifest, CAPABILITY_LABELS, } from "../lib/api"; import { checkPluginUpdates, updateMarketplacePlugin, uninstallMarketplacePlugin, type PluginUpdateInfo, } from "../lib/api/marketplace.js"; import { RegistryUpdateEscalationError, uninstallRegistryPlugin, updateRegistryPlugin, type RegistryUpdateOpts, } from "../lib/api/registry.js"; import { safeIconUrl } from "../lib/url.js"; import { cn } from "../lib/utils"; import { CaretNext } from "./ArrowIcons.js"; import { CapabilityConsentDialog } from "./CapabilityConsentDialog.js"; import { DialogError, getMutationError } from "./DialogError.js"; import { RouterLinkButton } from "./RouterLinkButton.js"; export interface PluginManagerProps { /** Admin manifest — used to check if marketplace is configured */ manifest?: AdminManifest; } export function PluginManager({ manifest }: PluginManagerProps) { const { t } = useLingui(); const queryClient = useQueryClient(); const toastManager = Toast.useToastManager(); const hasMarketplace = !!manifest?.marketplace; const { data: plugins, isLoading, error, } = useQuery({ queryKey: ["plugins"], queryFn: fetchPlugins, }); const { data: updates, refetch: refetchUpdates, isFetching: isCheckingUpdates, } = useQuery({ queryKey: ["plugin-updates"], queryFn: checkPluginUpdates, enabled: false, // Only fetch on demand }); const enableMutation = useMutation({ mutationFn: enablePlugin, onSuccess: (plugin) => { void queryClient.invalidateQueries({ queryKey: ["plugins"] }); void queryClient.invalidateQueries({ queryKey: ["manifest"] }); toastManager.add({ title: t`Plugin enabled`, description: t`${plugin.name} is now active`, }); }, onError: (err) => { toastManager.add({ title: t`Failed to enable plugin`, description: err instanceof Error ? err.message : t`An error occurred`, type: "error", }); }, }); const disableMutation = useMutation({ mutationFn: disablePlugin, onSuccess: (plugin) => { void queryClient.invalidateQueries({ queryKey: ["plugins"] }); void queryClient.invalidateQueries({ queryKey: ["manifest"] }); toastManager.add({ title: t`Plugin disabled`, description: t`${plugin.name} has been deactivated`, }); }, onError: (err) => { toastManager.add({ title: t`Failed to disable plugin`, description: err instanceof Error ? err.message : t`An error occurred`, type: "error", }); }, }); const updateMap = React.useMemo(() => { if (!updates) return new Map(); return new Map(updates.map((u) => [u.pluginId, u])); }, [updates]); const hasUpdatableSources = plugins?.some( (p) => p.source === "marketplace" || p.source === "registry", ); if (isLoading) { return (

{t`Plugins`}

{t`Loading plugins...`}
); } if (error) { return (

{t`Plugins`}

{t`Failed to load plugins: ${error.message}`}
); } return (

{t`Plugins`}

{hasUpdatableSources && ( )} {hasMarketplace && ( }> {t`Marketplace`} )} {t`${plugins?.length ?? 0} plugins`}

{t`Manage installed plugins. Enable or disable plugins to control their functionality.`}

{plugins?.map((plugin) => ( enableMutation.mutate(plugin.id)} onDisable={() => disableMutation.mutate(plugin.id)} isToggling={enableMutation.isPending || disableMutation.isPending} hasMarketplace={hasMarketplace} /> ))}
{plugins?.length === 0 && (

{t`No plugins configured`}

{hasMarketplace ? ( <> {t`Browse the`}{" "} {t`marketplace`} {" "} {t`to install plugins, or add them to your astro.config.mjs.`} ) : ( t`Add plugins to your astro.config.mjs to extend EmDash functionality.` )}

)}
); } interface PluginCardProps { plugin: PluginInfo; updateInfo?: PluginUpdateInfo; onEnable: () => void; onDisable: () => void; isToggling: boolean; /** Whether the marketplace is configured (controls "View in Marketplace" link) */ hasMarketplace: boolean; } function PluginCard({ plugin, updateInfo, onEnable, onDisable, isToggling, hasMarketplace, }: PluginCardProps) { const { t } = useLingui(); const [expanded, setExpanded] = React.useState(false); const [showUpdateConsent, setShowUpdateConsent] = React.useState(false); const [showUninstallConfirm, setShowUninstallConfirm] = React.useState(false); const [registryEscalation, setRegistryEscalation] = React.useState(null); const queryClient = useQueryClient(); const toastManager = Toast.useToastManager(); const isMarketplace = plugin.source === "marketplace"; const isRegistry = plugin.source === "registry"; const hasUpdate = !!updateInfo && updateInfo.installed !== updateInfo.latest; const updateMutation = useMutation({ mutationFn: (opts: RegistryUpdateOpts) => isRegistry ? updateRegistryPlugin(plugin.id, opts) : updateMarketplacePlugin(plugin.id, { confirmCapabilities: true }), onSuccess: () => { setShowUpdateConsent(false); setRegistryEscalation(null); void queryClient.invalidateQueries({ queryKey: ["plugins"] }); void queryClient.invalidateQueries({ queryKey: ["plugin-updates"] }); void queryClient.invalidateQueries({ queryKey: ["manifest"] }); toastManager.add({ title: t`Plugin updated`, description: t`${plugin.name} updated to v${updateInfo?.latest}`, }); }, onError: (err) => { if (err instanceof RegistryUpdateEscalationError) { setRegistryEscalation(err); setShowUpdateConsent(true); } }, }); const handleUpdateClick = () => { if (isRegistry) { // Preflight without confirm flags. Server returns the real // capability / route-visibility diff (or just updates if there // is none); `onError` opens the consent dialog populated with // the actual diff. setRegistryEscalation(null); updateMutation.mutate({}); } else { setShowUpdateConsent(true); } }; const handleUpdateConfirm = () => { if (isRegistry) { const opts: RegistryUpdateOpts = { confirmCapabilityChanges: true }; if (registryEscalation?.code === "ROUTE_VISIBILITY_ESCALATION") { opts.confirmRouteVisibilityChanges = true; } updateMutation.mutate(opts); } else { updateMutation.mutate({}); } }; const uninstallMutation = useMutation({ mutationFn: (deleteData: boolean) => isRegistry ? uninstallRegistryPlugin(plugin.id, { deleteData }) : uninstallMarketplacePlugin(plugin.id, { deleteData }), onSuccess: () => { setShowUninstallConfirm(false); void queryClient.invalidateQueries({ queryKey: ["plugins"] }); void queryClient.invalidateQueries({ queryKey: ["manifest"] }); toastManager.add({ title: t`Plugin uninstalled`, description: t`${plugin.name} has been removed`, }); }, }); const handleToggle = () => { if (plugin.enabled) { onDisable(); } else { onEnable(); } }; return ( <>
{/* Plugin icon */} {plugin.iconUrl ? ( ) : (
)} {/* Plugin info */}

{plugin.name}

v{plugin.version} {!plugin.enabled && {t`Disabled`}} {isMarketplace && {t`Marketplace`}} {hasUpdate && ( {t`v${updateInfo.latest} available`} )}
{/* Description */} {plugin.description && (

{plugin.description}

)} {/* Feature indicators + inline capabilities */}
{plugin.hasAdminPages && ( {t`Pages`} )} {plugin.hasDashboardWidgets && ( {t`Widgets`} )} {plugin.hasHooks && ( {t`Hooks`} )} {plugin.capabilities.length > 0 && ( { const label = CAPABILITY_LABELS[c]; return label ? t(label) : c; }) .join(", ")} > {plural(plugin.capabilities.length, { one: "# permission", other: "# permissions", })} )}
{/* Actions */}
{hasUpdate && ( )} {isMarketplace && hasMarketplace && ( } > {t`View in Marketplace`} )} {plugin.hasAdminPages && plugin.enabled && ( } /> )}
{/* Expanded details */} {expanded && (
{/* Capabilities */} {plugin.capabilities.length > 0 && (

{t`Capabilities`}

{plugin.capabilities.map((cap) => { const label = CAPABILITY_LABELS[cap]; const text = label ? t(label) : cap; return ( {text} ); })}
)} {/* Source */} {isMarketplace && (

{t`Source`}

{t`Installed from marketplace (v${plugin.marketplaceVersion || plugin.version})`}
)} {/* Package */} {plugin.package && (

{t`Package`}

{plugin.package}
)} {/* Timestamps */}
{plugin.installedAt && (
{t`Installed:`}{" "} {new Date(plugin.installedAt).toLocaleDateString()}
)} {plugin.activatedAt && (
{t`Last enabled:`}{" "} {new Date(plugin.activatedAt).toLocaleDateString()}
)} {plugin.deactivatedAt && !plugin.enabled && (
{t`Disabled:`}{" "} {new Date(plugin.deactivatedAt).toLocaleDateString()}
)}
{/* Uninstall button for any sandboxed source (marketplace + registry). */} {(isMarketplace || isRegistry) && (
)}
)}
{/* Update consent dialog */} {showUpdateConsent && updateInfo && ( { setShowUpdateConsent(false); setRegistryEscalation(null); updateMutation.reset(); }} /> )} {/* Uninstall confirmation */} {showUninstallConfirm && ( uninstallMutation.mutate(deleteData)} onCancel={() => { setShowUninstallConfirm(false); uninstallMutation.reset(); }} /> )} ); } // --------------------------------------------------------------------------- // Uninstall confirmation dialog // --------------------------------------------------------------------------- interface UninstallConfirmDialogProps { pluginName: string; isPending: boolean; error?: string | null; onConfirm: (deleteData: boolean) => void; onCancel: () => void; } export function UninstallConfirmDialog({ pluginName, isPending, error, onConfirm, onCancel, }: UninstallConfirmDialogProps) { const { t } = useLingui(); const [deleteData, setDeleteData] = React.useState(false); return (
!isPending && onCancel()} />

{t`Uninstall ${pluginName}?`}

{t`This will remove the plugin and its bundle from your site.`}

setDeleteData(checked)} label={t`Also delete plugin storage data`} />
); } export default PluginManager;