feat: disable plugins by default, require admin approval
This commit is contained in:
@@ -60,6 +60,15 @@ export default function AdminPluginsPage() {
|
|||||||
setMessage(null);
|
setMessage(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toggleRequirePluginApproval() {
|
||||||
|
setPolicy(prev => ({
|
||||||
|
...prev,
|
||||||
|
features: { ...prev.features, requirePluginApproval: !prev.features.requirePluginApproval },
|
||||||
|
}));
|
||||||
|
setPolicyDirty(true);
|
||||||
|
setMessage(null);
|
||||||
|
}
|
||||||
|
|
||||||
async function handleSavePolicy() {
|
async function handleSavePolicy() {
|
||||||
setSavingPolicy(true);
|
setSavingPolicy(true);
|
||||||
setMessage(null);
|
setMessage(null);
|
||||||
@@ -248,6 +257,7 @@ export default function AdminPluginsPage() {
|
|||||||
|
|
||||||
const pluginsEnabled = policy.features.pluginsEnabled ?? true;
|
const pluginsEnabled = policy.features.pluginsEnabled ?? true;
|
||||||
const pluginsUploadEnabled = policy.features.pluginsUploadEnabled ?? true;
|
const pluginsUploadEnabled = policy.features.pluginsUploadEnabled ?? true;
|
||||||
|
const requirePluginApproval = policy.features.requirePluginApproval ?? true;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
@@ -320,6 +330,17 @@ export default function AdminPluginsPage() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="px-4 py-3 flex items-center justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<span className="text-sm text-foreground">Require Admin Approval</span>
|
||||||
|
<p className="text-xs text-muted-foreground mt-0.5">User-uploaded plugins must be approved by an admin before they can be enabled</p>
|
||||||
|
</div>
|
||||||
|
<button onClick={toggleRequirePluginApproval}
|
||||||
|
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${requirePluginApproval ? 'bg-primary' : 'bg-muted-foreground/25 dark:bg-muted-foreground/50'}`}>
|
||||||
|
<span className={`inline-block h-3.5 w-3.5 transform rounded-full bg-background shadow transition-transform ${requirePluginApproval ? 'translate-x-[18px]' : 'translate-x-[3px]'}`} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Force enable / disable all */}
|
{/* Force enable / disable all */}
|
||||||
{plugins.length > 0 && (
|
{plugins.length > 0 && (
|
||||||
<div className="px-4 py-3 flex items-center justify-between gap-4">
|
<div className="px-4 py-3 flex items-center justify-between gap-4">
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ const STATUS_COLORS: Record<PluginStatus, string> = {
|
|||||||
|
|
||||||
export function PluginsSettings() {
|
export function PluginsSettings() {
|
||||||
const { plugins, installPlugin, uninstallPlugin, enablePlugin, disablePlugin, updatePluginSettings, initializePlugins, initialized } = usePluginStore();
|
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 [isUploading, setIsUploading] = useState(false);
|
||||||
const [expandedPlugin, setExpandedPlugin] = useState<string | null>(null);
|
const [expandedPlugin, setExpandedPlugin] = useState<string | null>(null);
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
@@ -75,6 +75,13 @@ export function PluginsSettings() {
|
|||||||
return;
|
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) {
|
if (plugin.enabled) {
|
||||||
disablePlugin(plugin.id);
|
disablePlugin(plugin.id);
|
||||||
toast.info(`Plugin "${plugin.name}" disabled`);
|
toast.info(`Plugin "${plugin.name}" disabled`);
|
||||||
@@ -108,20 +115,26 @@ export function PluginsSettings() {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{plugins.map(plugin => (
|
{plugins.map(plugin => {
|
||||||
|
const requireApproval = isFeatureEnabled('requirePluginApproval');
|
||||||
|
const isApproved = plugin.adminApproved || plugin.managed || isPluginApproved(plugin.id);
|
||||||
|
const needsApproval = requireApproval && !isApproved;
|
||||||
|
return (
|
||||||
<PluginCard
|
<PluginCard
|
||||||
key={plugin.id}
|
key={plugin.id}
|
||||||
plugin={plugin}
|
plugin={plugin}
|
||||||
isExpanded={expandedPlugin === plugin.id}
|
isExpanded={expandedPlugin === plugin.id}
|
||||||
isForceEnabled={plugin.forceEnabled || isPluginForceEnabled(plugin.id)}
|
isForceEnabled={plugin.forceEnabled || isPluginForceEnabled(plugin.id)}
|
||||||
isManaged={Boolean(plugin.managed)}
|
isManaged={Boolean(plugin.managed)}
|
||||||
|
needsApproval={needsApproval}
|
||||||
controlsDisabled={!initialized}
|
controlsDisabled={!initialized}
|
||||||
onToggleExpand={() => setExpandedPlugin(expandedPlugin === plugin.id ? null : plugin.id)}
|
onToggleExpand={() => setExpandedPlugin(expandedPlugin === plugin.id ? null : plugin.id)}
|
||||||
onToggle={() => handleToggle(plugin)}
|
onToggle={() => handleToggle(plugin)}
|
||||||
onUninstall={() => handleUninstall(plugin)}
|
onUninstall={() => handleUninstall(plugin)}
|
||||||
onUpdateSettings={(settings) => updatePluginSettings(plugin.id, settings)}
|
onUpdateSettings={(settings) => updatePluginSettings(plugin.id, settings)}
|
||||||
/>
|
/>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -166,6 +179,7 @@ interface PluginCardProps {
|
|||||||
isExpanded: boolean;
|
isExpanded: boolean;
|
||||||
isForceEnabled: boolean;
|
isForceEnabled: boolean;
|
||||||
isManaged: boolean;
|
isManaged: boolean;
|
||||||
|
needsApproval: boolean;
|
||||||
controlsDisabled: boolean;
|
controlsDisabled: boolean;
|
||||||
onToggleExpand: () => void;
|
onToggleExpand: () => void;
|
||||||
onToggle: () => void;
|
onToggle: () => void;
|
||||||
@@ -173,7 +187,7 @@ interface PluginCardProps {
|
|||||||
onUpdateSettings: (settings: Record<string, unknown>) => void;
|
onUpdateSettings: (settings: Record<string, unknown>) => 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 (
|
return (
|
||||||
<div className={cn(
|
<div className={cn(
|
||||||
'rounded-lg border transition-colors',
|
'rounded-lg border transition-colors',
|
||||||
@@ -197,6 +211,11 @@ function PluginCard({ plugin, isExpanded, isForceEnabled, isManaged, controlsDis
|
|||||||
<Server className="w-2.5 h-2.5" /> Managed
|
<Server className="w-2.5 h-2.5" /> Managed
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
{needsApproval && (
|
||||||
|
<span className="text-[10px] px-1.5 py-0.5 rounded-full font-medium bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400">
|
||||||
|
Awaiting approval
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 mt-0.5">
|
<div className="flex items-center gap-2 mt-0.5">
|
||||||
<span className="text-xs text-muted-foreground">{plugin.author}</span>
|
<span className="text-xs text-muted-foreground">{plugin.author}</span>
|
||||||
@@ -206,7 +225,7 @@ function PluginCard({ plugin, isExpanded, isForceEnabled, isManaged, controlsDis
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2 flex-shrink-0">
|
<div className="flex items-center gap-2 flex-shrink-0">
|
||||||
<ToggleSwitch checked={plugin.enabled} onChange={onToggle} disabled={controlsDisabled || isForceEnabled} />
|
<ToggleSwitch checked={plugin.enabled} onChange={onToggle} disabled={controlsDisabled || isForceEnabled || needsApproval} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -217,6 +236,10 @@ function PluginCard({ plugin, isExpanded, isForceEnabled, isManaged, controlsDis
|
|||||||
<p className="text-xs text-amber-600 dark:text-amber-400">This plugin is forced by an administrator and cannot be disabled or uninstalled.</p>
|
<p className="text-xs text-amber-600 dark:text-amber-400">This plugin is forced by an administrator and cannot be disabled or uninstalled.</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{needsApproval && (
|
||||||
|
<p className="text-xs text-orange-600 dark:text-orange-400">This plugin is awaiting admin approval and cannot be enabled until an administrator approves it.</p>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Description */}
|
{/* Description */}
|
||||||
{plugin.description && (
|
{plugin.description && (
|
||||||
<p className="text-xs text-muted-foreground">{plugin.description}</p>
|
<p className="text-xs text-muted-foreground">{plugin.description}</p>
|
||||||
|
|||||||
+6
-1
@@ -25,6 +25,7 @@ export interface SettingRestriction {
|
|||||||
export interface FeatureGates {
|
export interface FeatureGates {
|
||||||
pluginsEnabled: boolean;
|
pluginsEnabled: boolean;
|
||||||
pluginsUploadEnabled: boolean;
|
pluginsUploadEnabled: boolean;
|
||||||
|
requirePluginApproval: boolean;
|
||||||
themesEnabled: boolean;
|
themesEnabled: boolean;
|
||||||
sidebarAppsEnabled: boolean;
|
sidebarAppsEnabled: boolean;
|
||||||
userThemesEnabled: boolean;
|
userThemesEnabled: boolean;
|
||||||
@@ -41,8 +42,9 @@ export interface FeatureGates {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const DEFAULT_FEATURE_GATES: FeatureGates = {
|
export const DEFAULT_FEATURE_GATES: FeatureGates = {
|
||||||
pluginsEnabled: true,
|
pluginsEnabled: false,
|
||||||
pluginsUploadEnabled: true,
|
pluginsUploadEnabled: true,
|
||||||
|
requirePluginApproval: true,
|
||||||
themesEnabled: true,
|
themesEnabled: true,
|
||||||
sidebarAppsEnabled: true,
|
sidebarAppsEnabled: true,
|
||||||
userThemesEnabled: true,
|
userThemesEnabled: true,
|
||||||
@@ -80,6 +82,8 @@ export interface SettingsPolicy {
|
|||||||
themePolicy: ThemePolicy;
|
themePolicy: ThemePolicy;
|
||||||
/** Plugin IDs that are force-enabled (users cannot disable) */
|
/** Plugin IDs that are force-enabled (users cannot disable) */
|
||||||
forceEnabledPlugins: string[];
|
forceEnabledPlugins: string[];
|
||||||
|
/** Plugin IDs that have been approved by admin (users can enable) */
|
||||||
|
approvedPlugins: string[];
|
||||||
/** Theme IDs that are force-enabled (users cannot deactivate) */
|
/** Theme IDs that are force-enabled (users cannot deactivate) */
|
||||||
forceEnabledThemes: string[];
|
forceEnabledThemes: string[];
|
||||||
}
|
}
|
||||||
@@ -90,6 +94,7 @@ export const DEFAULT_POLICY: SettingsPolicy = {
|
|||||||
defaults: {},
|
defaults: {},
|
||||||
themePolicy: { ...DEFAULT_THEME_POLICY },
|
themePolicy: { ...DEFAULT_THEME_POLICY },
|
||||||
forceEnabledPlugins: [],
|
forceEnabledPlugins: [],
|
||||||
|
approvedPlugins: [],
|
||||||
forceEnabledThemes: [],
|
forceEnabledThemes: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
+7
-7
@@ -27,7 +27,7 @@ import {
|
|||||||
import { toast as appToast } from '@/stores/toast-store';
|
import { toast as appToast } from '@/stores/toast-store';
|
||||||
import { useAuthStore } from '@/stores/auth-store';
|
import { useAuthStore } from '@/stores/auth-store';
|
||||||
|
|
||||||
// ─── Permission helpers ──────────────────────────────────────
|
// --- Permission helpers --------------------------------------
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
function getPluginExternals(): any {
|
function getPluginExternals(): any {
|
||||||
@@ -61,7 +61,7 @@ function guardedHook<T extends (...args: never[]) => unknown>(
|
|||||||
return bus.register(plugin.id, handler, order);
|
return bus.register(plugin.id, handler, order);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Plugin-scoped storage ───────────────────────────────────
|
// --- Plugin-scoped storage -----------------------------------
|
||||||
|
|
||||||
function createPluginStorage(pluginId: string) {
|
function createPluginStorage(pluginId: string) {
|
||||||
const prefix = `plugin:${pluginId}:`;
|
const prefix = `plugin:${pluginId}:`;
|
||||||
@@ -93,7 +93,7 @@ function createPluginStorage(pluginId: string) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Plugin-scoped logger ────────────────────────────────────
|
// --- Plugin-scoped logger ------------------------------------
|
||||||
|
|
||||||
function createPluginLogger(pluginId: string) {
|
function createPluginLogger(pluginId: string) {
|
||||||
const tag = `[plugin:${pluginId}]`;
|
const tag = `[plugin:${pluginId}]`;
|
||||||
@@ -105,7 +105,7 @@ function createPluginLogger(pluginId: string) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── PluginAPI interface ─────────────────────────────────────
|
// --- PluginAPI interface -------------------------------------
|
||||||
|
|
||||||
export interface PluginAPI {
|
export interface PluginAPI {
|
||||||
plugin: { id: string; version: string; settings: Record<string, unknown> };
|
plugin: { id: string; version: string; settings: Record<string, unknown> };
|
||||||
@@ -316,7 +316,7 @@ export interface PluginHooksAPI {
|
|||||||
onSidebarAppChange: (handler: (...args: unknown[]) => unknown) => Disposable;
|
onSidebarAppChange: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Permission mapping for hooks ────────────────────────────
|
// --- Permission mapping for hooks ----------------------------
|
||||||
|
|
||||||
const HOOK_PERMISSIONS: Record<string, Permission> = {
|
const HOOK_PERMISSIONS: Record<string, Permission> = {
|
||||||
// Email
|
// Email
|
||||||
@@ -465,7 +465,7 @@ const HOOK_BUSES: Record<string, { register: (pluginId: string, handler: (...arg
|
|||||||
...Object.fromEntries(Object.entries(sidebarAppHooks)),
|
...Object.fromEntries(Object.entries(sidebarAppHooks)),
|
||||||
};
|
};
|
||||||
|
|
||||||
// ─── Slot registration bridge ────────────────────────────────
|
// --- Slot registration bridge --------------------------------
|
||||||
// Lazy import to avoid circular dependency — plugin-store imports plugin-api indirectly
|
// Lazy import to avoid circular dependency — plugin-store imports plugin-api indirectly
|
||||||
|
|
||||||
let registerSlotFn: ((name: SlotName, reg: { pluginId: string; component: React.ComponentType<Record<string, unknown>>; order: number }) => Disposable) | null = null;
|
let registerSlotFn: ((name: SlotName, reg: { pluginId: string; component: React.ComponentType<Record<string, unknown>>; order: number }) => Disposable) | null = null;
|
||||||
@@ -487,7 +487,7 @@ function registerSlot(
|
|||||||
return registerSlotFn(slotName, { pluginId, component, order });
|
return registerSlotFn(slotName, { pluginId, component, order });
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Factory ─────────────────────────────────────────────────
|
// --- Factory -------------------------------------------------
|
||||||
|
|
||||||
export function createPluginAPI(plugin: InstalledPlugin): PluginAPI {
|
export function createPluginAPI(plugin: InstalledPlugin): PluginAPI {
|
||||||
// Build hooks proxy — each hook method checks permission and registers on the right bus
|
// Build hooks proxy — each hook method checks permission and registers on the right bus
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import React from 'react';
|
|||||||
import ReactDOM from 'react-dom';
|
import ReactDOM from 'react-dom';
|
||||||
import * as ReactJSX from 'react/jsx-runtime';
|
import * as ReactJSX from 'react/jsx-runtime';
|
||||||
|
|
||||||
// ─── Shared React (window.__PLUGIN_EXTERNALS__) ─────────────
|
// --- Shared React (window.__PLUGIN_EXTERNALS__) -------------
|
||||||
|
|
||||||
export function exposePluginExternals(): void {
|
export function exposePluginExternals(): void {
|
||||||
if (typeof window === 'undefined') return;
|
if (typeof window === 'undefined') return;
|
||||||
@@ -20,7 +20,7 @@ export function exposePluginExternals(): void {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Active plugin tracking ──────────────────────────────────
|
// --- Active plugin tracking ----------------------------------
|
||||||
|
|
||||||
interface ActivePlugin {
|
interface ActivePlugin {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -31,7 +31,7 @@ interface ActivePlugin {
|
|||||||
|
|
||||||
const activePlugins = new Map<string, ActivePlugin>();
|
const activePlugins = new Map<string, ActivePlugin>();
|
||||||
|
|
||||||
// ─── Load a single plugin ────────────────────────────────────
|
// --- Load a single plugin ------------------------------------
|
||||||
|
|
||||||
type PluginStoreAccessor = {
|
type PluginStoreAccessor = {
|
||||||
setPluginStatus: (id: string, status: InstalledPlugin['status'], error?: string) => void;
|
setPluginStatus: (id: string, status: InstalledPlugin['status'], error?: string) => void;
|
||||||
@@ -101,7 +101,7 @@ export async function loadPlugin(plugin: InstalledPlugin): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Deactivate a single plugin ──────────────────────────────
|
// --- Deactivate a single plugin ------------------------------
|
||||||
|
|
||||||
export function deactivatePlugin(pluginId: string): void {
|
export function deactivatePlugin(pluginId: string): void {
|
||||||
const active = activePlugins.get(pluginId);
|
const active = activePlugins.get(pluginId);
|
||||||
@@ -127,7 +127,7 @@ export function deactivatePlugin(pluginId: string): void {
|
|||||||
console.info(`[plugin-loader] Plugin "${pluginId}" deactivated`);
|
console.info(`[plugin-loader] Plugin "${pluginId}" deactivated`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Activate all enabled plugins ────────────────────────────
|
// --- Activate all enabled plugins ----------------------------
|
||||||
|
|
||||||
export async function activateAllPlugins(plugins: InstalledPlugin[]): Promise<void> {
|
export async function activateAllPlugins(plugins: InstalledPlugin[]): Promise<void> {
|
||||||
// Ensure externals are exposed
|
// Ensure externals are exposed
|
||||||
@@ -139,7 +139,7 @@ export async function activateAllPlugins(plugins: InstalledPlugin[]): Promise<vo
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Deactivate all plugins ─────────────────────────────────
|
// --- Deactivate all plugins ---------------------------------
|
||||||
|
|
||||||
export function deactivateAllPlugins(): void {
|
export function deactivateAllPlugins(): void {
|
||||||
for (const pluginId of [...activePlugins.keys()]) {
|
for (const pluginId of [...activePlugins.keys()]) {
|
||||||
@@ -147,13 +147,13 @@ export function deactivateAllPlugins(): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Check if a plugin is active ─────────────────────────────
|
// --- Check if a plugin is active -----------------------------
|
||||||
|
|
||||||
export function isPluginActive(pluginId: string): boolean {
|
export function isPluginActive(pluginId: string): boolean {
|
||||||
return activePlugins.has(pluginId);
|
return activePlugins.has(pluginId);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Setup auto-disable callback ─────────────────────────────
|
// --- Setup auto-disable callback -----------------------------
|
||||||
|
|
||||||
export function setupAutoDisable(): void {
|
export function setupAutoDisable(): void {
|
||||||
pluginErrorTracker.setAutoDisableCallback((pluginId) => {
|
pluginErrorTracker.setAutoDisableCallback((pluginId) => {
|
||||||
|
|||||||
@@ -79,6 +79,8 @@ export interface InstalledPlugin {
|
|||||||
managed?: boolean;
|
managed?: boolean;
|
||||||
// True when plugin is admin-enforced and cannot be disabled locally.
|
// True when plugin is admin-enforced and cannot be disabled locally.
|
||||||
forceEnabled?: boolean;
|
forceEnabled?: boolean;
|
||||||
|
// True when plugin has been approved by an admin. Unapproved plugins cannot be enabled.
|
||||||
|
adminApproved?: boolean;
|
||||||
settingsSchema?: Record<string, SettingFieldSchema>;
|
settingsSchema?: Record<string, SettingFieldSchema>;
|
||||||
settings: Record<string, unknown>;
|
settings: Record<string, unknown>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -94,6 +94,7 @@ export const usePluginStore = create<PluginStoreState>()(
|
|||||||
status: 'installed',
|
status: 'installed',
|
||||||
managed: false,
|
managed: false,
|
||||||
forceEnabled: false,
|
forceEnabled: false,
|
||||||
|
adminApproved: false, // Requires admin approval before it can be enabled
|
||||||
settings: existing?.settings ?? {},
|
settings: existing?.settings ?? {},
|
||||||
settingsSchema: manifest.settingsSchema,
|
settingsSchema: manifest.settingsSchema,
|
||||||
};
|
};
|
||||||
@@ -144,6 +145,11 @@ export const usePluginStore = create<PluginStoreState>()(
|
|||||||
const plugin = plugins.find(p => p.id === id);
|
const plugin = plugins.find(p => p.id === id);
|
||||||
if (!plugin) return;
|
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)
|
// Ensure bridges are wired before loading (may not have run initializePlugins yet)
|
||||||
setPluginStoreAccessor({ setPluginStatus: get().setPluginStatus });
|
setPluginStoreAccessor({ setPluginStatus: get().setPluginStatus });
|
||||||
setSlotRegistrationBridge(get().registerSlot);
|
setSlotRegistrationBridge(get().registerSlot);
|
||||||
@@ -393,6 +399,7 @@ async function syncServerPlugins(
|
|||||||
status: sp.forceEnabled ? 'enabled' : 'installed',
|
status: sp.forceEnabled ? 'enabled' : 'installed',
|
||||||
managed: true,
|
managed: true,
|
||||||
forceEnabled: sp.forceEnabled,
|
forceEnabled: sp.forceEnabled,
|
||||||
|
adminApproved: true, // Server-managed plugins are always approved
|
||||||
settings: {},
|
settings: {},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ interface PolicyState {
|
|||||||
getForcedThemeId: (availableThemeIds?: string[]) => string | null;
|
getForcedThemeId: (availableThemeIds?: string[]) => string | null;
|
||||||
isThemeDisabled: (themeId: string, isBuiltIn: boolean) => boolean;
|
isThemeDisabled: (themeId: string, isBuiltIn: boolean) => boolean;
|
||||||
isPluginForceEnabled: (pluginId: string) => boolean;
|
isPluginForceEnabled: (pluginId: string) => boolean;
|
||||||
|
isPluginApproved: (pluginId: string) => boolean;
|
||||||
isThemeForceEnabled: (themeId: string) => boolean;
|
isThemeForceEnabled: (themeId: string) => boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,6 +85,10 @@ export const usePolicyStore = create<PolicyState>()((set, get) => ({
|
|||||||
return (get().policy.forceEnabledPlugins || []).includes(pluginId);
|
return (get().policy.forceEnabledPlugins || []).includes(pluginId);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
isPluginApproved: (pluginId) => {
|
||||||
|
return (get().policy.approvedPlugins || []).includes(pluginId);
|
||||||
|
},
|
||||||
|
|
||||||
isThemeForceEnabled: (themeId) => {
|
isThemeForceEnabled: (themeId) => {
|
||||||
return (get().policy.forceEnabledThemes || []).includes(themeId);
|
return (get().policy.forceEnabledThemes || []).includes(themeId);
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user