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); },