From 37bc88dbad14d1f70add788ddc27b115dbc9e3bf Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Thu, 26 Mar 2026 18:59:30 +0100 Subject: [PATCH] feat: implement force enable/disable functionality for plugins and themes --- app/admin/plugins/page.tsx | 123 ++++++++++++++++++++++- app/admin/themes/page.tsx | 120 +++++++++++++++++++++- app/api/admin/plugins/route.ts | 14 ++- app/api/admin/themes/route.ts | 14 ++- components/settings/plugins-settings.tsx | 16 ++- components/settings/themes-settings.tsx | 13 ++- lib/admin/plugin-registry.ts | 6 +- lib/admin/types.ts | 6 ++ stores/policy-store.ts | 10 ++ 9 files changed, 301 insertions(+), 21 deletions(-) diff --git a/app/admin/plugins/page.tsx b/app/admin/plugins/page.tsx index db3a5523..51c700d9 100644 --- a/app/admin/plugins/page.tsx +++ b/app/admin/plugins/page.tsx @@ -1,7 +1,7 @@ 'use client'; import { useEffect, useState, useRef } from 'react'; -import { Upload, Trash2, Power, AlertTriangle, Loader2, Package, Save, Shield } from 'lucide-react'; +import { Upload, Trash2, Power, PowerOff, AlertTriangle, Loader2, Package, Save, Shield, Lock, LockOpen } from 'lucide-react'; import type { SettingsPolicy } from '@/lib/admin/types'; import { DEFAULT_POLICY } from '@/lib/admin/types'; @@ -13,6 +13,7 @@ interface PluginEntry { description: string; type: string; enabled: boolean; + forceEnabled?: boolean; permissions: string[]; installedAt: string; updatedAt: string; @@ -130,6 +131,88 @@ export default function AdminPluginsPage() { } } + async function toggleForceEnabled(id: string, forceEnabled: boolean) { + setMessage(null); + // If force-enabling, also ensure the plugin is enabled + const body: Record = { id, forceEnabled }; + if (forceEnabled) body.enabled = true; + + const res = await fetch('/api/admin/plugins', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + + if (res.ok) { + setPlugins(prev => prev.map(p => p.id === id ? { ...p, forceEnabled, ...(forceEnabled ? { enabled: true } : {}) } : p)); + // Also update policy + setPolicy(prev => { + const current = prev.forceEnabledPlugins || []; + return { + ...prev, + forceEnabledPlugins: forceEnabled + ? [...current.filter(pid => pid !== id), id] + : current.filter(pid => pid !== id), + }; + }); + setPolicyDirty(true); + } else { + const data = await res.json(); + setMessage({ type: 'error', text: data.error || 'Update failed' }); + } + } + + async function forceEnableAll() { + setMessage(null); + const disabled = plugins.filter(p => !p.enabled); + if (disabled.length === 0) { + setMessage({ type: 'success', text: 'All plugins are already enabled' }); + return; + } + let failed = 0; + for (const p of disabled) { + const res = await fetch('/api/admin/plugins', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ id: p.id, enabled: true }), + }); + if (!res.ok) failed++; + } + setPlugins(prev => prev.map(p => failed === 0 ? { ...p, enabled: true } : p)); + if (failed === 0) { + await fetchPlugins(); + setMessage({ type: 'success', text: `All ${disabled.length} plugin(s) enabled` }); + } else { + await fetchPlugins(); + setMessage({ type: 'error', text: `${failed} plugin(s) failed to enable` }); + } + } + + async function forceDisableAll() { + setMessage(null); + const enabled = plugins.filter(p => p.enabled); + if (enabled.length === 0) { + setMessage({ type: 'success', text: 'All plugins are already disabled' }); + return; + } + let failed = 0; + for (const p of enabled) { + const res = await fetch('/api/admin/plugins', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ id: p.id, enabled: false }), + }); + if (!res.ok) failed++; + } + if (failed === 0) { + await fetchPlugins(); + setMessage({ type: 'success', text: `All ${enabled.length} plugin(s) disabled` }); + } else { + await fetchPlugins(); + setMessage({ type: 'error', text: `${failed} plugin(s) failed to disable` }); + } + } + async function deletePlugin(id: string, name: string) { if (!confirm(`Remove plugin "${name}"? This cannot be undone.`)) return; @@ -214,6 +297,32 @@ export default function AdminPluginsPage() { + + {/* Force enable / disable all */} + {plugins.length > 0 && ( +
+
+ Force Enable / Disable All +

Bulk toggle all deployed plugins at once

+
+
+ + +
+
+ )} @@ -243,6 +352,11 @@ export default function AdminPluginsPage() { {plugin.enabled ? 'Enabled' : 'Disabled'} + {plugin.forceEnabled && ( + + Forced + + )} {plugin.description && (

{plugin.description}

@@ -261,6 +375,13 @@ export default function AdminPluginsPage() {
+
+ {/* Force enable / disable all */} + {themes.length > 0 && ( +
+
+ Force Enable / Disable All +

Bulk toggle all deployed themes at once

+
+
+ + +
+
+ )} + {/* Default Theme */}
@@ -399,6 +505,11 @@ export default function AdminThemesPage() { {theme.enabled ? 'Enabled' : 'Disabled'} + {theme.forceEnabled && ( + + Forced + + )}
{theme.description && (

{theme.description}

@@ -409,6 +520,13 @@ export default function AdminThemesPage() {
+
diff --git a/components/settings/themes-settings.tsx b/components/settings/themes-settings.tsx index e97b95c7..40eb3043 100644 --- a/components/settings/themes-settings.tsx +++ b/components/settings/themes-settings.tsx @@ -4,7 +4,7 @@ import { useState, useRef, useEffect } from 'react'; import { useThemeStore } from '@/stores/theme-store'; import { SettingsSection, SettingItem } from './settings-section'; import { cn } from '@/lib/utils'; -import { Upload, Trash2, Check, Palette } from 'lucide-react'; +import { Upload, Trash2, Check, Palette, Lock } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { toast } from '@/stores/toast-store'; import type { InstalledTheme } from '@/lib/plugin-types'; @@ -14,7 +14,7 @@ export function ThemesSettings() { const { installedThemes, activeThemeId, installTheme, uninstallTheme, activateTheme } = useThemeStore(); const [isUploading, setIsUploading] = useState(false); const fileInputRef = useRef(null); - const { isFeatureEnabled, isThemeDisabled, getThemePolicy } = usePolicyStore(); + const { isFeatureEnabled, isThemeDisabled, getThemePolicy, isThemeForceEnabled } = usePolicyStore(); const canUpload = isFeatureEnabled('userThemesEnabled'); const themePolicy = getThemePolicy(); @@ -93,6 +93,7 @@ export function ThemesSettings() { isActive={activeThemeId === theme.id} isBuiltIn={theme.builtIn} isDefault={themePolicy.defaultThemeId === theme.id} + isForceEnabled={isThemeForceEnabled(theme.id)} variants={theme.variants} onActivate={() => handleActivate(theme.id)} onRemove={!theme.builtIn ? () => handleUninstall(theme) : undefined} @@ -135,12 +136,13 @@ interface ThemeCardProps { isActive: boolean; isBuiltIn: boolean; isDefault?: boolean; + isForceEnabled?: boolean; variants?: ('light' | 'dark')[]; onActivate: () => void; onRemove?: () => void; } -function ThemeCard({ name, author, preview, isActive, isDefault, variants, onActivate, onRemove }: ThemeCardProps) { +function ThemeCard({ name, author, preview, isActive, isDefault, isForceEnabled, variants, onActivate, onRemove }: ThemeCardProps) { return (