From 6ee0f6a40ab20bf924e5c393a4d34fb91b662e25 Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Thu, 2 Apr 2026 13:50:02 +0200
Subject: [PATCH] feat: disable plugins by default, require admin approval
---
app/admin/plugins/page.tsx | 21 +++++++++++++++
components/settings/plugins-settings.tsx | 33 ++++++++++++++++++++----
lib/admin/types.ts | 7 ++++-
lib/plugin-api.ts | 14 +++++-----
lib/plugin-loader.ts | 16 ++++++------
lib/plugin-types.ts | 2 ++
stores/plugin-store.ts | 7 +++++
stores/policy-store.ts | 5 ++++
8 files changed, 84 insertions(+), 21 deletions(-)
diff --git a/app/admin/plugins/page.tsx b/app/admin/plugins/page.tsx
index 74b33dbb..9d95a323 100644
--- a/app/admin/plugins/page.tsx
+++ b/app/admin/plugins/page.tsx
@@ -60,6 +60,15 @@ export default function AdminPluginsPage() {
setMessage(null);
}
+ function toggleRequirePluginApproval() {
+ setPolicy(prev => ({
+ ...prev,
+ features: { ...prev.features, requirePluginApproval: !prev.features.requirePluginApproval },
+ }));
+ setPolicyDirty(true);
+ setMessage(null);
+ }
+
async function handleSavePolicy() {
setSavingPolicy(true);
setMessage(null);
@@ -248,6 +257,7 @@ export default function AdminPluginsPage() {
const pluginsEnabled = policy.features.pluginsEnabled ?? true;
const pluginsUploadEnabled = policy.features.pluginsUploadEnabled ?? true;
+ const requirePluginApproval = policy.features.requirePluginApproval ?? true;
return (
@@ -320,6 +330,17 @@ export default function AdminPluginsPage() {
+
+
+
Require Admin Approval
+
User-uploaded plugins must be approved by an admin before they can be enabled
+
+
+
+
{/* Force enable / disable all */}
{plugins.length > 0 && (
diff --git a/components/settings/plugins-settings.tsx b/components/settings/plugins-settings.tsx
index c583e6a6..cc3a4519 100644
--- a/components/settings/plugins-settings.tsx
+++ b/components/settings/plugins-settings.tsx
@@ -20,7 +20,7 @@ const STATUS_COLORS: Record
= {
export function PluginsSettings() {
const { plugins, installPlugin, uninstallPlugin, enablePlugin, disablePlugin, updatePluginSettings, initializePlugins, initialized } = usePluginStore();
- const { isFeatureEnabled, isPluginForceEnabled, fetchPolicy, loaded } = usePolicyStore();
+ const { isFeatureEnabled, isPluginForceEnabled, isPluginApproved, fetchPolicy, loaded } = usePolicyStore();
const [isUploading, setIsUploading] = useState(false);
const [expandedPlugin, setExpandedPlugin] = useState(null);
const fileInputRef = useRef(null);
@@ -75,6 +75,13 @@ export function PluginsSettings() {
return;
}
+ const requireApproval = isFeatureEnabled('requirePluginApproval');
+ const isApproved = plugin.adminApproved || plugin.managed || isPluginApproved(plugin.id);
+ if (!plugin.enabled && requireApproval && !isApproved) {
+ toast.info(`Plugin "${plugin.name}" requires admin approval before it can be enabled`);
+ return;
+ }
+
if (plugin.enabled) {
disablePlugin(plugin.id);
toast.info(`Plugin "${plugin.name}" disabled`);
@@ -108,20 +115,26 @@ export function PluginsSettings() {
) : (
- {plugins.map(plugin => (
+ {plugins.map(plugin => {
+ const requireApproval = isFeatureEnabled('requirePluginApproval');
+ const isApproved = plugin.adminApproved || plugin.managed || isPluginApproved(plugin.id);
+ const needsApproval = requireApproval && !isApproved;
+ return (
setExpandedPlugin(expandedPlugin === plugin.id ? null : plugin.id)}
onToggle={() => handleToggle(plugin)}
onUninstall={() => handleUninstall(plugin)}
onUpdateSettings={(settings) => updatePluginSettings(plugin.id, settings)}
/>
- ))}
+ );
+ })}
)}
@@ -166,6 +179,7 @@ interface PluginCardProps {
isExpanded: boolean;
isForceEnabled: boolean;
isManaged: boolean;
+ needsApproval: boolean;
controlsDisabled: boolean;
onToggleExpand: () => void;
onToggle: () => void;
@@ -173,7 +187,7 @@ interface PluginCardProps {
onUpdateSettings: (settings: Record) => void;
}
-function PluginCard({ plugin, isExpanded, isForceEnabled, isManaged, controlsDisabled, onToggleExpand, onToggle, onUninstall, onUpdateSettings }: PluginCardProps) {
+function PluginCard({ plugin, isExpanded, isForceEnabled, isManaged, needsApproval, controlsDisabled, onToggleExpand, onToggle, onUninstall, onUpdateSettings }: PluginCardProps) {
return (
Managed
)}
+ {needsApproval && (
+
+ Awaiting approval
+
+ )}
{plugin.author}
@@ -206,7 +225,7 @@ function PluginCard({ plugin, isExpanded, isForceEnabled, isManaged, controlsDis
-
+
@@ -217,6 +236,10 @@ function PluginCard({ plugin, isExpanded, isForceEnabled, isManaged, controlsDis
This plugin is forced by an administrator and cannot be disabled or uninstalled.
)}
+ {needsApproval && (
+ This plugin is awaiting admin approval and cannot be enabled until an administrator approves it.
+ )}
+
{/* Description */}
{plugin.description && (
{plugin.description}
diff --git a/lib/admin/types.ts b/lib/admin/types.ts
index 5454fcdf..19114661 100644
--- a/lib/admin/types.ts
+++ b/lib/admin/types.ts
@@ -25,6 +25,7 @@ export interface SettingRestriction {
export interface FeatureGates {
pluginsEnabled: boolean;
pluginsUploadEnabled: boolean;
+ requirePluginApproval: boolean;
themesEnabled: boolean;
sidebarAppsEnabled: boolean;
userThemesEnabled: boolean;
@@ -41,8 +42,9 @@ export interface FeatureGates {
}
export const DEFAULT_FEATURE_GATES: FeatureGates = {
- pluginsEnabled: true,
+ pluginsEnabled: false,
pluginsUploadEnabled: true,
+ requirePluginApproval: true,
themesEnabled: true,
sidebarAppsEnabled: true,
userThemesEnabled: true,
@@ -80,6 +82,8 @@ export interface SettingsPolicy {
themePolicy: ThemePolicy;
/** Plugin IDs that are force-enabled (users cannot disable) */
forceEnabledPlugins: string[];
+ /** Plugin IDs that have been approved by admin (users can enable) */
+ approvedPlugins: string[];
/** Theme IDs that are force-enabled (users cannot deactivate) */
forceEnabledThemes: string[];
}
@@ -90,6 +94,7 @@ export const DEFAULT_POLICY: SettingsPolicy = {
defaults: {},
themePolicy: { ...DEFAULT_THEME_POLICY },
forceEnabledPlugins: [],
+ approvedPlugins: [],
forceEnabledThemes: [],
};
diff --git a/lib/plugin-api.ts b/lib/plugin-api.ts
index 8d9fec30..2a2d4353 100644
--- a/lib/plugin-api.ts
+++ b/lib/plugin-api.ts
@@ -27,7 +27,7 @@ import {
import { toast as appToast } from '@/stores/toast-store';
import { useAuthStore } from '@/stores/auth-store';
-// ─── Permission helpers ──────────────────────────────────────
+// --- Permission helpers --------------------------------------
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function getPluginExternals(): any {
@@ -61,7 +61,7 @@ function guardedHook unknown>(
return bus.register(plugin.id, handler, order);
}
-// ─── Plugin-scoped storage ───────────────────────────────────
+// --- Plugin-scoped storage -----------------------------------
function createPluginStorage(pluginId: string) {
const prefix = `plugin:${pluginId}:`;
@@ -93,7 +93,7 @@ function createPluginStorage(pluginId: string) {
};
}
-// ─── Plugin-scoped logger ────────────────────────────────────
+// --- Plugin-scoped logger ------------------------------------
function createPluginLogger(pluginId: string) {
const tag = `[plugin:${pluginId}]`;
@@ -105,7 +105,7 @@ function createPluginLogger(pluginId: string) {
};
}
-// ─── PluginAPI interface ─────────────────────────────────────
+// --- PluginAPI interface -------------------------------------
export interface PluginAPI {
plugin: { id: string; version: string; settings: Record };
@@ -316,7 +316,7 @@ export interface PluginHooksAPI {
onSidebarAppChange: (handler: (...args: unknown[]) => unknown) => Disposable;
}
-// ─── Permission mapping for hooks ────────────────────────────
+// --- Permission mapping for hooks ----------------------------
const HOOK_PERMISSIONS: Record = {
// Email
@@ -465,7 +465,7 @@ const HOOK_BUSES: Record>; order: number }) => Disposable) | null = null;
@@ -487,7 +487,7 @@ function registerSlot(
return registerSlotFn(slotName, { pluginId, component, order });
}
-// ─── Factory ─────────────────────────────────────────────────
+// --- Factory -------------------------------------------------
export function createPluginAPI(plugin: InstalledPlugin): PluginAPI {
// Build hooks proxy — each hook method checks permission and registers on the right bus
diff --git a/lib/plugin-loader.ts b/lib/plugin-loader.ts
index 9dc835b3..7eb1719a 100644
--- a/lib/plugin-loader.ts
+++ b/lib/plugin-loader.ts
@@ -8,7 +8,7 @@ import React from 'react';
import ReactDOM from 'react-dom';
import * as ReactJSX from 'react/jsx-runtime';
-// ─── Shared React (window.__PLUGIN_EXTERNALS__) ─────────────
+// --- Shared React (window.__PLUGIN_EXTERNALS__) -------------
export function exposePluginExternals(): void {
if (typeof window === 'undefined') return;
@@ -20,7 +20,7 @@ export function exposePluginExternals(): void {
};
}
-// ─── Active plugin tracking ──────────────────────────────────
+// --- Active plugin tracking ----------------------------------
interface ActivePlugin {
id: string;
@@ -31,7 +31,7 @@ interface ActivePlugin {
const activePlugins = new Map();
-// ─── Load a single plugin ────────────────────────────────────
+// --- Load a single plugin ------------------------------------
type PluginStoreAccessor = {
setPluginStatus: (id: string, status: InstalledPlugin['status'], error?: string) => void;
@@ -101,7 +101,7 @@ export async function loadPlugin(plugin: InstalledPlugin): Promise {
}
}
-// ─── Deactivate a single plugin ──────────────────────────────
+// --- Deactivate a single plugin ------------------------------
export function deactivatePlugin(pluginId: string): void {
const active = activePlugins.get(pluginId);
@@ -127,7 +127,7 @@ export function deactivatePlugin(pluginId: string): void {
console.info(`[plugin-loader] Plugin "${pluginId}" deactivated`);
}
-// ─── Activate all enabled plugins ────────────────────────────
+// --- Activate all enabled plugins ----------------------------
export async function activateAllPlugins(plugins: InstalledPlugin[]): Promise {
// Ensure externals are exposed
@@ -139,7 +139,7 @@ export async function activateAllPlugins(plugins: InstalledPlugin[]): Promise {
diff --git a/lib/plugin-types.ts b/lib/plugin-types.ts
index 804afeae..1936fc6e 100644
--- a/lib/plugin-types.ts
+++ b/lib/plugin-types.ts
@@ -79,6 +79,8 @@ export interface InstalledPlugin {
managed?: boolean;
// True when plugin is admin-enforced and cannot be disabled locally.
forceEnabled?: boolean;
+ // True when plugin has been approved by an admin. Unapproved plugins cannot be enabled.
+ adminApproved?: boolean;
settingsSchema?: Record;
settings: Record;
}
diff --git a/stores/plugin-store.ts b/stores/plugin-store.ts
index eb2bc160..a7513c09 100644
--- a/stores/plugin-store.ts
+++ b/stores/plugin-store.ts
@@ -94,6 +94,7 @@ export const usePluginStore = create()(
status: 'installed',
managed: false,
forceEnabled: false,
+ adminApproved: false, // Requires admin approval before it can be enabled
settings: existing?.settings ?? {},
settingsSchema: manifest.settingsSchema,
};
@@ -144,6 +145,11 @@ export const usePluginStore = create()(
const plugin = plugins.find(p => p.id === id);
if (!plugin) return;
+ // Block enabling if plugin requires admin approval and hasn't been approved
+ const requireApproval = usePolicyStore.getState().isFeatureEnabled('requirePluginApproval');
+ const isApproved = plugin.adminApproved || plugin.managed || usePolicyStore.getState().isPluginApproved(id);
+ if (requireApproval && !isApproved) return;
+
// Ensure bridges are wired before loading (may not have run initializePlugins yet)
setPluginStoreAccessor({ setPluginStatus: get().setPluginStatus });
setSlotRegistrationBridge(get().registerSlot);
@@ -393,6 +399,7 @@ async function syncServerPlugins(
status: sp.forceEnabled ? 'enabled' : 'installed',
managed: true,
forceEnabled: sp.forceEnabled,
+ adminApproved: true, // Server-managed plugins are always approved
settings: {},
};
diff --git a/stores/policy-store.ts b/stores/policy-store.ts
index fe2c5d13..0472512e 100644
--- a/stores/policy-store.ts
+++ b/stores/policy-store.ts
@@ -15,6 +15,7 @@ interface PolicyState {
getForcedThemeId: (availableThemeIds?: string[]) => string | null;
isThemeDisabled: (themeId: string, isBuiltIn: boolean) => boolean;
isPluginForceEnabled: (pluginId: string) => boolean;
+ isPluginApproved: (pluginId: string) => boolean;
isThemeForceEnabled: (themeId: string) => boolean;
}
@@ -84,6 +85,10 @@ export const usePolicyStore = create()((set, get) => ({
return (get().policy.forceEnabledPlugins || []).includes(pluginId);
},
+ isPluginApproved: (pluginId) => {
+ return (get().policy.approvedPlugins || []).includes(pluginId);
+ },
+
isThemeForceEnabled: (themeId) => {
return (get().policy.forceEnabledThemes || []).includes(themeId);
},