diff --git a/.env.example b/.env.example index 0f678b58..86967030 100644 --- a/.env.example +++ b/.env.example @@ -132,6 +132,14 @@ LOGIN_COMPANY_NAME=Bulwark Webmail # URL for the company website link on the login page LOGIN_WEBSITE_URL=https://bulwarkmail.org +# ============================================================================= +# Extension Directory / Marketplace +# ============================================================================= + +# URL of the BulwarkMail extension directory for the admin marketplace. +# Set this to enable browsing and installing plugins/themes from the directory. +# EXTENSION_DIRECTORY_URL=https://extensions.bulwarkmail.org + # ============================================================================= # Legacy Build-time Variables (still supported as fallback) # ============================================================================= diff --git a/app/[locale]/page.tsx b/app/[locale]/page.tsx index b72193cd..b89c7a40 100644 --- a/app/[locale]/page.tsx +++ b/app/[locale]/page.tsx @@ -46,6 +46,7 @@ import { ResizeHandle } from "@/components/layout/resize-handle"; import { Button } from "@/components/ui/button"; import { useConfig } from "@/hooks/use-config"; import { usePluginStore } from "@/stores/plugin-store"; +import { useThemeStore } from "@/stores/theme-store"; export default function Home() { @@ -286,8 +287,10 @@ export default function Home() { }, [checkAuth]); // Initialize plugins on mount (re-activates enabled plugins after refresh) + // Also syncs server-managed plugins and themes to the client useEffect(() => { usePluginStore.getState().initializePlugins(); + useThemeStore.getState().syncServerThemes(); }, []); // Hydrate persisted column widths from localStorage diff --git a/app/admin/layout.tsx b/app/admin/layout.tsx index 8d52db75..891b1013 100644 --- a/app/admin/layout.tsx +++ b/app/admin/layout.tsx @@ -19,6 +19,7 @@ import { BookUser, HardDrive, ArrowLeft, + Store, } from 'lucide-react'; import { cn } from '@/lib/utils'; import { useConfig } from '@/hooks/use-config'; @@ -47,6 +48,7 @@ const NAV_GROUPS = [ items: [ { href: '/admin/plugins', label: 'Plugins', icon: Puzzle }, { href: '/admin/themes', label: 'Themes', icon: SwatchBook }, + { href: '/admin/marketplace', label: 'Marketplace', icon: Store }, ], }, { diff --git a/app/admin/marketplace/page.tsx b/app/admin/marketplace/page.tsx new file mode 100644 index 00000000..08526eba --- /dev/null +++ b/app/admin/marketplace/page.tsx @@ -0,0 +1,354 @@ +'use client'; + +import { useEffect, useState, useCallback } from 'react'; +import { Search, Download, Check, Loader2, Store, Puzzle, SwatchBook, Star, Filter } from 'lucide-react'; + +interface Extension { + slug: string; + name: string; + type: 'plugin' | 'theme'; + pluginType: string | null; + description: string; + permissions: string[]; + tags: string[]; + totalDownloads: number; + featured: boolean; + minAppVersion: string | null; + latestVersion: string | null; + installed: boolean; + author: { + displayName: string; + githubLogin: string; + avatarUrl: string | null; + } | null; +} + +interface SearchResult { + data: Extension[]; + meta: { + page: number; + perPage: number; + total: number; + }; +} + +type TypeFilter = 'all' | 'plugin' | 'theme'; + +export default function AdminMarketplacePage() { + const [extensions, setExtensions] = useState([]); + const [loading, setLoading] = useState(true); + const [query, setQuery] = useState(''); + const [typeFilter, setTypeFilter] = useState('all'); + const [page, setPage] = useState(1); + const [total, setTotal] = useState(0); + const [perPage] = useState(12); + const [installing, setInstalling] = useState(null); + const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null); + const [error, setError] = useState(null); + + const fetchExtensions = useCallback(async () => { + setLoading(true); + setError(null); + try { + const params = new URLSearchParams(); + if (query) params.set('q', query); + if (typeFilter !== 'all') params.set('type', typeFilter); + params.set('page', String(page)); + params.set('perPage', String(perPage)); + params.set('sort', 'newest'); + + const res = await fetch(`/api/admin/marketplace?${params}`); + if (!res.ok) { + const data = await res.json().catch(() => ({})); + setError(data.error || 'Failed to connect to extension directory'); + setExtensions([]); + return; + } + + const data: SearchResult = await res.json(); + setExtensions(data.data || []); + setTotal(data.meta?.total || 0); + } catch { + setError('Failed to connect to extension directory. Make sure it is running.'); + setExtensions([]); + } finally { + setLoading(false); + } + }, [query, typeFilter, page, perPage]); + + useEffect(() => { + fetchExtensions(); + }, [fetchExtensions]); + + // Debounced search + const [searchInput, setSearchInput] = useState(''); + useEffect(() => { + const t = setTimeout(() => { + setQuery(searchInput); + setPage(1); + }, 300); + return () => clearTimeout(t); + }, [searchInput]); + + async function handleInstall(ext: Extension) { + setInstalling(ext.slug); + setMessage(null); + + try { + const res = await fetch('/api/admin/marketplace', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + slug: ext.slug, + version: ext.latestVersion || '1.0.0', + type: ext.type, + }), + }); + + const data = await res.json(); + + if (res.ok) { + const warnings = data.warnings?.length ? ` (${data.warnings.length} warning(s))` : ''; + setMessage({ type: 'success', text: `"${ext.name}" installed successfully${warnings}` }); + // Mark as installed in the UI + setExtensions(prev => prev.map(e => e.slug === ext.slug ? { ...e, installed: true } : e)); + } else { + setMessage({ type: 'error', text: data.error || 'Installation failed' }); + } + } catch { + setMessage({ type: 'error', text: 'Installation failed — network error' }); + } finally { + setInstalling(null); + } + } + + const totalPages = Math.ceil(total / perPage); + + return ( +
+
+

Marketplace

+

+ Browse and install plugins and themes from the BulwarkMail extension directory +

+
+ + {message && ( +
+ {message.text} +
+ )} + + {/* Search & Filters */} +
+
+ + setSearchInput(e.target.value)} + className="w-full h-9 pl-9 pr-3 rounded-md border border-input bg-background text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring/20 focus:border-ring" + /> +
+
+ {(['all', 'plugin', 'theme'] as const).map((t) => ( + + ))} +
+
+ + {/* Error State */} + {error && ( +
+ +

{error}

+

+ Start the extension directory server on the configured port +

+ +
+ )} + + {/* Loading State */} + {loading && !error && ( +
+ + Searching extensions... +
+ )} + + {/* Empty State */} + {!loading && !error && extensions.length === 0 && ( +
+ +

No extensions found

+ {query && ( +

+ Try a different search term +

+ )} +
+ )} + + {/* Extension Grid */} + {!loading && !error && extensions.length > 0 && ( + <> +
+ {total} extension{total !== 1 ? 's' : ''} found +
+
+ {extensions.map((ext) => ( + handleInstall(ext)} + /> + ))} +
+ + {/* Pagination */} + {totalPages > 1 && ( +
+ + + Page {page} of {totalPages} + + +
+ )} + + )} +
+ ); +} + +function ExtensionCard({ + extension, + installing, + onInstall, +}: { + extension: Extension; + installing: boolean; + onInstall: () => void; +}) { + const isPlugin = extension.type === 'plugin'; + + return ( +
+
+ {/* Header */} +
+
+ {isPlugin ? ( + + ) : ( + + )} +
+
+
+ {extension.name} + {extension.featured && ( + + )} +
+
+ + {isPlugin ? (extension.pluginType || 'plugin') : 'theme'} + + {extension.author && ( + + by {extension.author.displayName} + + )} +
+
+
+ + {/* Description */} +

+ {extension.description} +

+ + {/* Tags */} + {extension.tags && extension.tags.length > 0 && ( +
+ {extension.tags.slice(0, 3).map(tag => ( + + {tag} + + ))} +
+ )} + + {/* Footer */} +
+
+ + + {extension.totalDownloads.toLocaleString()} + + {extension.permissions && extension.permissions.length > 0 && ( + + {extension.permissions.length} permission{extension.permissions.length !== 1 ? 's' : ''} + + )} +
+ + {extension.installed ? ( + + + Installed + + ) : ( + + )} +
+
+
+ ); +} diff --git a/app/admin/plugins/page.tsx b/app/admin/plugins/page.tsx index 51c700d9..c969d150 100644 --- a/app/admin/plugins/page.tsx +++ b/app/admin/plugins/page.tsx @@ -50,6 +50,15 @@ export default function AdminPluginsPage() { setMessage(null); } + function togglePluginsUploadEnabled() { + setPolicy(prev => ({ + ...prev, + features: { ...prev.features, pluginsUploadEnabled: !prev.features.pluginsUploadEnabled }, + })); + setPolicyDirty(true); + setMessage(null); + } + async function handleSavePolicy() { setSavingPolicy(true); setMessage(null); @@ -237,6 +246,7 @@ export default function AdminPluginsPage() { } const pluginsEnabled = policy.features.pluginsEnabled ?? true; + const pluginsUploadEnabled = policy.features.pluginsUploadEnabled ?? true; return (
@@ -298,6 +308,17 @@ export default function AdminPluginsPage() {
+
+
+ User Plugin Uploads +

Allow users to upload plugin ZIP files in Settings

+
+ +
+ {/* Force enable / disable all */} {plugins.length > 0 && (
diff --git a/app/admin/policy/page.tsx b/app/admin/policy/page.tsx index 427e4cc1..ff2be065 100644 --- a/app/admin/policy/page.tsx +++ b/app/admin/policy/page.tsx @@ -6,7 +6,7 @@ import type { SettingsPolicy, FeatureGates } from '@/lib/admin/types'; import { DEFAULT_FEATURE_GATES, DEFAULT_POLICY } from '@/lib/admin/types'; // Feature gates managed on their own admin pages (excluded from this list) -const EXCLUDED_FEATURE_GATES: (keyof FeatureGates)[] = ['pluginsEnabled', 'themesEnabled', 'userThemesEnabled']; +const EXCLUDED_FEATURE_GATES: (keyof FeatureGates)[] = ['pluginsEnabled', 'pluginsUploadEnabled', 'themesEnabled', 'userThemesEnabled']; const FEATURE_GATE_LABELS: Partial> = { sidebarAppsEnabled: { label: 'Sidebar Apps', description: 'Allow custom web apps in navigation rail' }, diff --git a/app/api/admin/marketplace/route.ts b/app/api/admin/marketplace/route.ts new file mode 100644 index 00000000..13abde0f --- /dev/null +++ b/app/api/admin/marketplace/route.ts @@ -0,0 +1,232 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { requireAdminAuth, getClientIP } from '@/lib/admin/session'; +import { auditLog } from '@/lib/admin/audit'; +import { logger } from '@/lib/logger'; +import { + savePlugin, + saveTheme, + getPluginRegistry, + getThemeRegistry, + type ServerPlugin, + type ServerTheme, +} from '@/lib/admin/plugin-registry'; +import JSZip from 'jszip'; +import { MAX_PLUGIN_SIZE, MAX_THEME_SIZE, ALL_PERMISSIONS, ALLOWED_PLUGIN_FILES } from '@/lib/plugin-types'; +import { sanitizeThemeCSS, validateThemeCSSSafety } from '@/lib/theme-loader'; + +const DIRECTORY_URL = process.env.EXTENSION_DIRECTORY_URL || 'http://localhost:3001'; + +/** + * GET /api/admin/marketplace — Search/browse the extension directory + * Proxies to the extension directory API + */ +export async function GET(request: NextRequest) { + try { + const result = await requireAdminAuth(); + if ('error' in result) return result.error; + + const { searchParams } = request.nextUrl; + const url = new URL('/api/v1/extensions', DIRECTORY_URL); + + // Forward all search params + for (const [key, value] of searchParams.entries()) { + url.searchParams.set(key, value); + } + + const res = await fetch(url.toString(), { + headers: { 'Accept': 'application/json' }, + signal: AbortSignal.timeout(10000), + }); + + if (!res.ok) { + return NextResponse.json( + { error: 'Failed to fetch from extension directory' }, + { status: 502 } + ); + } + + const data = await res.json(); + + // Enrich with install status + const [pluginRegistry, themeRegistry] = await Promise.all([ + getPluginRegistry(), + getThemeRegistry(), + ]); + + const installedPlugins = new Set(pluginRegistry.plugins.map(p => p.id)); + const installedThemes = new Set(themeRegistry.themes.map(t => t.id)); + + if (data.data) { + data.data = data.data.map((ext: Record) => ({ + ...ext, + installed: ext.type === 'theme' + ? installedThemes.has(ext.slug as string) + : installedPlugins.has(ext.slug as string), + })); + } + + return NextResponse.json(data, { + headers: { 'Cache-Control': 'no-store' }, + }); + } catch (error) { + logger.error('Marketplace search error', { error: error instanceof Error ? error.message : 'Unknown error' }); + return NextResponse.json({ error: 'Failed to connect to extension directory' }, { status: 502 }); + } +} + +/** + * POST /api/admin/marketplace — Install an extension from the directory + * Body: { slug: string, version: string, type: 'plugin' | 'theme' } + */ +export async function POST(request: NextRequest) { + try { + const result = await requireAdminAuth(); + if ('error' in result) return result.error; + + const ip = getClientIP(request); + const { slug, version, type } = await request.json(); + + if (!slug || !version || !type) { + return NextResponse.json({ error: 'Missing slug, version, or type' }, { status: 400 }); + } + + if (type !== 'plugin' && type !== 'theme') { + return NextResponse.json({ error: 'Invalid type' }, { status: 400 }); + } + + // Download the bundle from the directory + const bundleUrl = new URL(`/api/v1/bundle/${encodeURIComponent(slug)}/${encodeURIComponent(version)}`, DIRECTORY_URL); + const bundleRes = await fetch(bundleUrl.toString(), { + signal: AbortSignal.timeout(30000), + }); + + if (!bundleRes.ok) { + return NextResponse.json( + { error: `Failed to download bundle: ${bundleRes.status}` }, + { status: 502 } + ); + } + + const buffer = await bundleRes.arrayBuffer(); + const maxSize = type === 'theme' ? MAX_THEME_SIZE : MAX_PLUGIN_SIZE; + + if (buffer.byteLength > maxSize) { + return NextResponse.json( + { error: `Bundle exceeds ${type === 'theme' ? '1 MB' : '5 MB'} size limit` }, + { status: 400 } + ); + } + + // Parse the ZIP + let zip: JSZip; + try { + zip = await JSZip.loadAsync(buffer); + } catch { + return NextResponse.json({ error: 'Invalid ZIP file from directory' }, { status: 400 }); + } + + // Find root directory + const entries = Object.keys(zip.files); + const topDirs = new Set(entries.map(e => e.split('/')[0])); + let root = ''; + if (topDirs.size === 1) { + const dir = [...topDirs][0]; + if (zip.files[dir + '/'] || entries.some(e => e.startsWith(dir + '/'))) { + root = dir + '/'; + } + } + + // Read manifest + const manifestFile = zip.file(root + 'manifest.json'); + if (!manifestFile) { + return NextResponse.json({ error: 'Bundle missing manifest.json' }, { status: 400 }); + } + + let manifest: Record; + try { + manifest = JSON.parse(await manifestFile.async('string')); + } catch { + return NextResponse.json({ error: 'Invalid manifest.json in bundle' }, { status: 400 }); + } + + const now = new Date().toISOString(); + + if (type === 'theme') { + // Read theme.css + const cssFile = zip.file(root + 'theme.css'); + if (!cssFile) { + return NextResponse.json({ error: 'Theme bundle missing theme.css' }, { status: 400 }); + } + + let css = await cssFile.async('string'); + + // Validate and sanitize CSS + const warnings: string[] = []; + const safety = validateThemeCSSSafety(css); + if (!safety.valid) { + const sanitized = sanitizeThemeCSS(css); + css = sanitized.css; + warnings.push(...sanitized.warnings); + } + + const theme: ServerTheme = { + id: (manifest.id as string) || slug, + name: (manifest.name as string) || slug, + version: (manifest.version as string) || version, + author: (manifest.author as string) || 'Unknown', + description: (manifest.description as string) || '', + variants: (manifest.variants as string[]) || ['light', 'dark'], + enabled: true, + installedAt: now, + updatedAt: now, + }; + + await saveTheme(theme, css); + await auditLog('marketplace.install_theme', { id: theme.id, name: theme.name, version: theme.version, slug }, ip); + + return NextResponse.json({ success: true, theme, warnings }); + } else { + // Plugin installation + // Read entrypoint JS + const entrypoint = (manifest.entrypoint as string) || 'index.js'; + const jsFile = zip.file(root + entrypoint); + if (!jsFile) { + return NextResponse.json({ error: `Bundle missing entrypoint: ${entrypoint}` }, { status: 400 }); + } + + const code = await jsFile.async('string'); + + // Validate permissions + const permissions = Array.isArray(manifest.permissions) ? manifest.permissions as string[] : []; + const validPerms = new Set(ALL_PERMISSIONS as readonly string[]); + const unknownPerms = permissions.filter(p => !validPerms.has(p)); + + const warnings: string[] = []; + if (unknownPerms.length > 0) { + warnings.push(`Unknown permissions: ${unknownPerms.join(', ')}`); + } + + const plugin: ServerPlugin = { + id: (manifest.id as string) || slug, + name: (manifest.name as string) || slug, + version: (manifest.version as string) || version, + author: (manifest.author as string) || 'Unknown', + description: (manifest.description as string) || '', + type: (manifest.type as string) || 'hook', + permissions, + entrypoint, + enabled: true, + installedAt: now, + updatedAt: now, + }; + + await savePlugin(plugin, code); + await auditLog('marketplace.install_plugin', { id: plugin.id, name: plugin.name, version: plugin.version, slug }, ip); + + return NextResponse.json({ success: true, plugin, warnings }); + } + } catch (error) { + logger.error('Marketplace install error', { error: error instanceof Error ? error.message : 'Unknown error' }); + return NextResponse.json({ error: 'Installation failed' }, { status: 500 }); + } +} diff --git a/app/api/plugins/route.ts b/app/api/plugins/route.ts new file mode 100644 index 00000000..e1a4d424 --- /dev/null +++ b/app/api/plugins/route.ts @@ -0,0 +1,55 @@ +import { NextResponse } from 'next/server'; +import { getPluginRegistry, getThemeRegistry } from '@/lib/admin/plugin-registry'; +import { logger } from '@/lib/logger'; + +/** + * GET /api/plugins — Public endpoint for clients to discover server-managed plugins & themes + * + * Returns all enabled plugins and themes so the client can sync them to IndexedDB. + * No admin auth required — this is how regular users receive plugins/themes. + */ +export async function GET() { + try { + const [pluginRegistry, themeRegistry] = await Promise.all([ + getPluginRegistry(), + getThemeRegistry(), + ]); + + // Only serve enabled plugins + const plugins = pluginRegistry.plugins + .filter(p => p.enabled) + .map(p => ({ + id: p.id, + name: p.name, + version: p.version, + author: p.author, + description: p.description, + type: p.type, + permissions: p.permissions, + entrypoint: p.entrypoint, + forceEnabled: p.forceEnabled || false, + settingsSchema: undefined, // Will be read from the bundle's manifest + })); + + // Only serve enabled themes + const themes = themeRegistry.themes + .filter(t => t.enabled) + .map(t => ({ + id: t.id, + name: t.name, + version: t.version, + author: t.author, + description: t.description, + variants: t.variants, + forceEnabled: t.forceEnabled || false, + })); + + return NextResponse.json( + { plugins, themes }, + { headers: { 'Cache-Control': 'no-store' } }, + ); + } catch (error) { + logger.error('Plugin list error', { error: error instanceof Error ? error.message : 'Unknown error' }); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} diff --git a/components/settings/plugins-settings.tsx b/components/settings/plugins-settings.tsx index 4bf5430d..c583e6a6 100644 --- a/components/settings/plugins-settings.tsx +++ b/components/settings/plugins-settings.tsx @@ -1,11 +1,11 @@ 'use client'; -import { useState, useRef } from 'react'; +import { useState, useRef, useEffect } from 'react'; import { usePluginStore } from '@/stores/plugin-store'; import { usePolicyStore } from '@/stores/policy-store'; import { SettingsSection, SettingItem, ToggleSwitch } from './settings-section'; import { cn } from '@/lib/utils'; -import { Upload, Trash2, AlertTriangle, Puzzle, Lock } from 'lucide-react'; +import { Upload, Trash2, AlertTriangle, Puzzle, Lock, Server } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { toast } from '@/stores/toast-store'; import type { InstalledPlugin, PluginStatus, SettingFieldSchema } from '@/lib/plugin-types'; @@ -19,17 +19,31 @@ const STATUS_COLORS: Record = { }; export function PluginsSettings() { - const { plugins, installPlugin, uninstallPlugin, enablePlugin, disablePlugin, updatePluginSettings } = usePluginStore(); - const { isFeatureEnabled, isPluginForceEnabled } = usePolicyStore(); + const { plugins, installPlugin, uninstallPlugin, enablePlugin, disablePlugin, updatePluginSettings, initializePlugins, initialized } = usePluginStore(); + const { isFeatureEnabled, isPluginForceEnabled, fetchPolicy, loaded } = usePolicyStore(); const [isUploading, setIsUploading] = useState(false); const [expandedPlugin, setExpandedPlugin] = useState(null); const fileInputRef = useRef(null); + useEffect(() => { + if (!loaded) { + fetchPolicy(); + } + initializePlugins(); + }, [fetchPolicy, initializePlugins, loaded]); + if (!isFeatureEnabled('pluginsEnabled')) { return null; } + const pluginUploadsEnabled = isFeatureEnabled('pluginsUploadEnabled'); + const handleUpload = async (e: React.ChangeEvent) => { + if (!pluginUploadsEnabled) { + toast.info('Plugin uploads are disabled by your administrator'); + return; + } + const file = e.target.files?.[0]; if (!file) return; @@ -53,7 +67,14 @@ export function PluginsSettings() { }; const handleToggle = async (plugin: InstalledPlugin) => { - if (isPluginForceEnabled(plugin.id)) return; + if (!initialized) return; + + const isForceEnabled = plugin.forceEnabled || isPluginForceEnabled(plugin.id); + if (isForceEnabled) { + toast.info(`Plugin "${plugin.name}" is forced by admin and cannot be disabled`); + return; + } + if (plugin.enabled) { disablePlugin(plugin.id); toast.info(`Plugin "${plugin.name}" disabled`); @@ -64,6 +85,14 @@ export function PluginsSettings() { }; const handleUninstall = (plugin: InstalledPlugin) => { + if (!initialized) return; + + const isForceEnabled = plugin.forceEnabled || isPluginForceEnabled(plugin.id); + if (isForceEnabled) { + toast.info(`Plugin "${plugin.name}" is forced by admin and cannot be uninstalled`); + return; + } + uninstallPlugin(plugin.id); toast.success(`Plugin "${plugin.name}" removed`); }; @@ -84,7 +113,9 @@ export function PluginsSettings() { key={plugin.id} plugin={plugin} isExpanded={expandedPlugin === plugin.id} - isForceEnabled={isPluginForceEnabled(plugin.id)} + isForceEnabled={plugin.forceEnabled || isPluginForceEnabled(plugin.id)} + isManaged={Boolean(plugin.managed)} + controlsDisabled={!initialized} onToggleExpand={() => setExpandedPlugin(expandedPlugin === plugin.id ? null : plugin.id)} onToggle={() => handleToggle(plugin)} onUninstall={() => handleUninstall(plugin)} @@ -94,26 +125,36 @@ export function PluginsSettings() {
)} + {!initialized && plugins.length > 0 && ( +

Syncing plugin policy and managed state...

+ )} + {/* Upload */} - - - - + {pluginUploadsEnabled ? ( + + + + + ) : ( + + Disabled by administrator policy + + )} ); } @@ -124,13 +165,15 @@ interface PluginCardProps { plugin: InstalledPlugin; isExpanded: boolean; isForceEnabled: boolean; + isManaged: boolean; + controlsDisabled: boolean; onToggleExpand: () => void; onToggle: () => void; onUninstall: () => void; onUpdateSettings: (settings: Record) => void; } -function PluginCard({ plugin, isExpanded, isForceEnabled, onToggleExpand, onToggle, onUninstall, onUpdateSettings }: PluginCardProps) { +function PluginCard({ plugin, isExpanded, isForceEnabled, isManaged, controlsDisabled, onToggleExpand, onToggle, onUninstall, onUpdateSettings }: PluginCardProps) { return (
{isForceEnabled && ( - Admin enforced + Forced + + )} + {isManaged && ( + + Managed )}
@@ -158,13 +206,17 @@ function PluginCard({ plugin, isExpanded, isForceEnabled, onToggleExpand, onTogg
- +
{/* Expanded Details */} {isExpanded && (
+ {isForceEnabled && ( +

This plugin is forced by an administrator and cannot be disabled or uninstalled.

+ )} + {/* Description */} {plugin.description && (

{plugin.description}

@@ -210,7 +262,7 @@ function PluginCard({ plugin, isExpanded, isForceEnabled, onToggleExpand, onTogg {/* Uninstall */}
- diff --git a/lib/admin/types.ts b/lib/admin/types.ts index faf5c48f..37ac20b0 100644 --- a/lib/admin/types.ts +++ b/lib/admin/types.ts @@ -24,6 +24,7 @@ export interface SettingRestriction { export interface FeatureGates { pluginsEnabled: boolean; + pluginsUploadEnabled: boolean; themesEnabled: boolean; sidebarAppsEnabled: boolean; userThemesEnabled: boolean; @@ -40,6 +41,7 @@ export interface FeatureGates { export const DEFAULT_FEATURE_GATES: FeatureGates = { pluginsEnabled: true, + pluginsUploadEnabled: true, themesEnabled: true, sidebarAppsEnabled: true, userThemesEnabled: true, diff --git a/lib/plugin-types.ts b/lib/plugin-types.ts index 05c8a6a6..06456def 100644 --- a/lib/plugin-types.ts +++ b/lib/plugin-types.ts @@ -73,6 +73,10 @@ export interface InstalledPlugin { enabled: boolean; status: PluginStatus; error?: string; + // True when plugin was delivered from server-side admin registry. + managed?: boolean; + // True when plugin is admin-enforced and cannot be disabled locally. + forceEnabled?: boolean; settingsSchema?: Record; settings: Record; } diff --git a/stores/plugin-store.ts b/stores/plugin-store.ts index 16d2c5cc..56406aa2 100644 --- a/stores/plugin-store.ts +++ b/stores/plugin-store.ts @@ -14,6 +14,7 @@ import { extractPlugin } from '@/lib/plugin-validator'; import { loadPlugin, deactivatePlugin, setPluginStoreAccessor, setupAutoDisable } from '@/lib/plugin-loader'; import { setSlotRegistrationBridge } from '@/lib/plugin-api'; import { removeAllPluginHooks } from '@/lib/plugin-hooks'; +import { usePolicyStore } from '@/stores/policy-store'; // ─── Slot State ────────────────────────────────────────────── @@ -30,6 +31,8 @@ function emptySlots(): Record { return slots; } +let pluginInitializationPromise: Promise | null = null; + // ─── Store Interface ───────────────────────────────────────── interface PluginStoreState { @@ -88,6 +91,8 @@ export const usePluginStore = create()( entrypoint: manifest.entrypoint, enabled: false, // Start disabled, user must enable status: 'installed', + managed: false, + forceEnabled: false, settings: existing?.settings ?? {}, settingsSchema: manifest.settingsSchema, }; @@ -108,6 +113,8 @@ export const usePluginStore = create()( const { plugins } = get(); const plugin = plugins.find(p => p.id === id); if (!plugin) return; + const forceEnabledByPolicy = usePolicyStore.getState().isPluginForceEnabled(id); + if (plugin.forceEnabled || forceEnabledByPolicy) return; // Deactivate if running deactivatePlugin(id); @@ -155,6 +162,11 @@ export const usePluginStore = create()( disablePlugin: (id: string) => { const { plugins } = get(); + const plugin = plugins.find(p => p.id === id); + if (!plugin) return; + const forceEnabledByPolicy = usePolicyStore.getState().isPluginForceEnabled(id); + if (plugin.forceEnabled || forceEnabledByPolicy) return; + deactivatePlugin(id); set({ @@ -207,20 +219,42 @@ export const usePluginStore = create()( initializePlugins: async () => { if (get().initialized) return; - // Wire up bridges - setPluginStoreAccessor({ - setPluginStatus: get().setPluginStatus, - }); - setSlotRegistrationBridge(get().registerSlot); - setupAutoDisable(); - - // Load all enabled plugins - const enabledPlugins = get().plugins.filter(p => p.enabled && p.status !== 'error'); - for (const plugin of enabledPlugins) { - await loadPlugin(plugin); + if (pluginInitializationPromise) { + await pluginInitializationPromise; + return; } - set({ initialized: true }); + pluginInitializationPromise = (async () => { + // Clean up any previously persisted duplicates by plugin id. + const deduped = dedupeInstalledPlugins(get().plugins); + if (deduped.length !== get().plugins.length) { + set({ plugins: deduped }); + } + + // Wire up bridges + setPluginStoreAccessor({ + setPluginStatus: get().setPluginStatus, + }); + setSlotRegistrationBridge(get().registerSlot); + setupAutoDisable(); + + // Sync server-managed plugins before loading + await syncServerPlugins(get, set); + + // Load all enabled plugins + const enabledPlugins = get().plugins.filter(p => p.enabled && p.status !== 'error'); + for (const plugin of enabledPlugins) { + await loadPlugin(plugin); + } + + set({ initialized: true }); + })(); + + try { + await pluginInitializationPromise; + } finally { + pluginInitializationPromise = null; + } }, }), { @@ -237,6 +271,8 @@ export const usePluginStore = create()( onRehydrateStorage: () => { return (state) => { if (state) { + state.plugins = markServerManagedPlugins(state.plugins); + state.plugins = dedupeInstalledPlugins(state.plugins); // Ensure slots are initialized after rehydration state.slots = emptySlots(); state.initialized = false; @@ -246,3 +282,205 @@ export const usePluginStore = create()( } ) ); + +// ─── Server Plugin Sync ────────────────────────────────────── + +interface ServerPluginInfo { + id: string; + name: string; + version: string; + author: string; + description: string; + type: string; + permissions: string[]; + entrypoint: string; + forceEnabled: boolean; +} + +const SERVER_MANAGED_KEY = 'server-managed-plugin-ids'; + +function getServerManagedPluginIds(): Set { + try { + const raw = localStorage.getItem(SERVER_MANAGED_KEY); + return raw ? new Set(JSON.parse(raw)) : new Set(); + } catch { return new Set(); } +} + +function setServerManagedPluginIds(ids: Set): void { + try { + localStorage.setItem(SERVER_MANAGED_KEY, JSON.stringify([...ids])); + } catch { /* ok */ } +} + +function dedupeInstalledPlugins(plugins: InstalledPlugin[]): InstalledPlugin[] { + const byId = new Map(); + + for (const plugin of plugins) { + const existing = byId.get(plugin.id); + if (!existing) { + byId.set(plugin.id, plugin); + continue; + } + + byId.set(plugin.id, { + ...existing, + ...plugin, + enabled: existing.enabled || plugin.enabled, + status: existing.enabled || plugin.enabled ? 'enabled' : plugin.status, + settings: { ...existing.settings, ...plugin.settings }, + error: plugin.error ?? existing.error, + managed: existing.managed || plugin.managed, + forceEnabled: existing.forceEnabled || plugin.forceEnabled, + }); + } + + return [...byId.values()]; +} + +function markServerManagedPlugins(plugins: InstalledPlugin[]): InstalledPlugin[] { + const serverIds = getServerManagedPluginIds(); + if (serverIds.size === 0) return plugins; + return plugins.map(plugin => + serverIds.has(plugin.id) ? { ...plugin, managed: true } : plugin + ); +} + +/** + * Sync server-managed plugins to the client. + * Downloads missing plugin bundles and installs them into IndexedDB + store. + * Force-enabled plugins are auto-enabled. + * Plugins removed from the server are cleaned up from the client. + */ +async function syncServerPlugins( + get: () => PluginStoreState, + set: (partial: Partial | ((state: PluginStoreState) => Partial)) => void, +): Promise { + try { + const res = await fetch('/api/plugins'); + if (!res.ok) return; + + const data: { plugins: ServerPluginInfo[] } = await res.json(); + if (!data.plugins || !Array.isArray(data.plugins)) return; + + const serverPlugins = data.plugins; + const serverPluginIds = new Set(serverPlugins.map(p => p.id)); + + // Track which plugins came from the server (so we can clean up stale ones) + const prevServerIds = getServerManagedPluginIds(); + + // Install or update server plugins that are missing/outdated locally + for (const sp of serverPlugins) { + const local = get().plugins.find(p => p.id === sp.id); + + if (!local) { + // New server plugin — download and install + const code = await downloadPluginBundle(sp.id); + if (!code) continue; + + await pluginStorage.saveCode(sp.id, code); + + const plugin: InstalledPlugin = { + id: sp.id, + name: sp.name, + version: sp.version, + author: sp.author, + description: sp.description, + type: sp.type as InstalledPlugin['type'], + permissions: sp.permissions, + entrypoint: sp.entrypoint, + enabled: sp.forceEnabled, + status: sp.forceEnabled ? 'enabled' : 'installed', + managed: true, + forceEnabled: sp.forceEnabled, + settings: {}, + }; + + set(state => { + if (state.plugins.some(p => p.id === sp.id)) { + return {}; + } + return { plugins: [...state.plugins, plugin] }; + }); + } else if (local.version !== sp.version) { + // Version changed — re-download bundle + const code = await downloadPluginBundle(sp.id); + if (!code) continue; + + await pluginStorage.saveCode(sp.id, code); + + set(state => ({ + plugins: state.plugins.map(p => + p.id === sp.id + ? { + ...p, + name: sp.name, + version: sp.version, + author: sp.author, + description: sp.description, + permissions: sp.permissions, + entrypoint: sp.entrypoint, + managed: true, + forceEnabled: sp.forceEnabled, + } + : p + ), + })); + } else if (local.managed !== true || local.forceEnabled !== sp.forceEnabled) { + set(state => ({ + plugins: state.plugins.map(p => + p.id === sp.id + ? { + ...p, + managed: true, + forceEnabled: sp.forceEnabled, + } + : p + ), + })); + } else if (sp.forceEnabled && !local.enabled) { + // Force-enable if the server says so but client has it disabled + set(state => ({ + plugins: state.plugins.map(p => + p.id === sp.id + ? { ...p, enabled: true, status: 'enabled' as const, managed: true, forceEnabled: true } + : p + ), + })); + } + } + + // Ensure no duplicate IDs remain after sync. + set(state => ({ plugins: dedupeInstalledPlugins(state.plugins) })); + + // Remove plugins that were previously server-managed but no longer on the server + const staleIds = [...prevServerIds].filter(id => !serverPluginIds.has(id)); + if (staleIds.length > 0) { + for (const id of staleIds) { + deactivatePlugin(id); + removeAllPluginHooks(id); + pluginStorage.deleteCode(id); + } + const staleSet = new Set(staleIds); + set(state => ({ + plugins: state.plugins.filter(p => !staleSet.has(p.id)), + })); + } + + // Persist current server plugin IDs for future cleanup + setServerManagedPluginIds(serverPluginIds); + } catch { + // Sync failure is non-fatal — client continues with local plugins + console.warn('[plugin-store] Server plugin sync failed, using local plugins only'); + } +} + +async function downloadPluginBundle(pluginId: string): Promise { + try { + const res = await fetch(`/api/admin/plugins/${encodeURIComponent(pluginId)}/bundle`); + if (!res.ok) return null; + return await res.text(); + } catch { + console.warn(`[plugin-store] Failed to download bundle for plugin "${pluginId}"`); + return null; + } +} diff --git a/stores/theme-store.ts b/stores/theme-store.ts index ecb7bcbf..ec3937b7 100644 --- a/stores/theme-store.ts +++ b/stores/theme-store.ts @@ -26,6 +26,7 @@ interface ThemeState { installTheme: (file: File) => Promise<{ success: boolean; error?: string; warnings?: string[] }>; uninstallTheme: (id: string) => void; activateTheme: (id: string | null) => void; + syncServerThemes: () => Promise; } const getSystemTheme = (): 'light' | 'dark' => { @@ -52,6 +53,7 @@ const applyTheme = (theme: 'light' | 'dark') => { }; let mediaQueryCleanup: (() => void) | null = null; +let themeSyncPromise: Promise | null = null; export const useThemeStore = create()( persist( @@ -243,6 +245,104 @@ export const useThemeStore = create()( applyCustomThemeCSS(theme, resolvedTheme); set({ activeThemeId: id }); }, + + syncServerThemes: async () => { + if (themeSyncPromise) { + await themeSyncPromise; + return; + } + + themeSyncPromise = (async () => { + try { + const res = await fetch('/api/plugins'); + if (!res.ok) return; + + const data: { themes: ServerThemeInfo[] } = await res.json(); + if (!data.themes || !Array.isArray(data.themes)) return; + + const serverThemes = data.themes; + + for (const st of serverThemes) { + const local = get().installedThemes.find(t => t.id === st.id); + + if (!local) { + // Download and install new server theme + const css = await downloadThemeCSS(st.id); + if (!css) continue; + + const sanitized = sanitizeThemeCSS(css); + const theme: InstalledTheme = { + id: st.id, + name: st.name, + version: st.version, + author: st.author, + description: st.description || '', + css: sanitized.css, + variants: st.variants as ThemeVariant[], + enabled: true, + builtIn: false, + }; + + await pluginStorage.saveThemeCSS(st.id, sanitized.css); + set(state => { + if (state.installedThemes.some(t => t.id === st.id)) { + return {}; + } + return { + installedThemes: [...state.installedThemes, theme], + }; + }); + + // If this is force-enabled and no theme is active, activate it + if (st.forceEnabled && !get().activeThemeId) { + applyCustomThemeCSS(theme, get().resolvedTheme); + set({ activeThemeId: st.id }); + } + } else if (!local.builtIn && local.version !== st.version) { + // Version changed — re-download CSS + const css = await downloadThemeCSS(st.id); + if (!css) continue; + + const sanitized = sanitizeThemeCSS(css); + await pluginStorage.saveThemeCSS(st.id, sanitized.css); + + const updatedTheme = { + ...local, + name: st.name, + version: st.version, + author: st.author, + description: st.description || '', + css: sanitized.css, + variants: st.variants as ThemeVariant[], + }; + + set(state => ({ + installedThemes: state.installedThemes.map(t => + t.id === st.id ? updatedTheme : t + ), + })); + + // Re-apply if active + if (get().activeThemeId === st.id) { + applyCustomThemeCSS(updatedTheme, get().resolvedTheme); + } + } + } + + set(state => ({ + installedThemes: dedupeInstalledThemes(state.installedThemes), + })); + } catch { + console.warn('[theme-store] Server theme sync failed'); + } + })(); + + try { + await themeSyncPromise; + } finally { + themeSyncPromise = null; + } + }, }), { name: 'theme-storage', @@ -284,4 +384,48 @@ function applyCustomThemeCSS(theme: InstalledTheme, resolvedTheme: 'light' | 'da return; } injectThemeCSS(theme.css); +} + +// ─── Server Theme Sync Helpers ─────────────────────────────── + +interface ServerThemeInfo { + id: string; + name: string; + version: string; + author: string; + description: string; + variants: string[]; + forceEnabled: boolean; +} + +function dedupeInstalledThemes(themes: InstalledTheme[]): InstalledTheme[] { + const byId = new Map(); + + for (const theme of themes) { + const existing = byId.get(theme.id); + if (!existing) { + byId.set(theme.id, theme); + continue; + } + + byId.set(theme.id, { + ...existing, + ...theme, + builtIn: existing.builtIn || theme.builtIn, + enabled: existing.enabled || theme.enabled, + }); + } + + return [...byId.values()]; +} + +async function downloadThemeCSS(themeId: string): Promise { + try { + const res = await fetch(`/api/admin/themes/${encodeURIComponent(themeId)}/css`); + if (!res.ok) return null; + return await res.text(); + } catch { + console.warn(`[theme-store] Failed to download CSS for theme "${themeId}"`); + return null; + } } \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json index e7ff3a26..315f280c 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -36,6 +36,7 @@ ".next/dev/types/**/*.ts" ], "exclude": [ - "node_modules" + "node_modules", + "repos" ] }