feat: enforce forced/managed plugins and policy. Split user upload permission

This commit is contained in:
Linus Rath
2026-03-27 16:33:17 +01:00
parent ba6b25f3f1
commit 415c961937
14 changed files with 1159 additions and 43 deletions
+8
View File
@@ -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)
# =============================================================================
+3
View File
@@ -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
+2
View File
@@ -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 },
],
},
{
+354
View File
@@ -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<Extension[]>([]);
const [loading, setLoading] = useState(true);
const [query, setQuery] = useState('');
const [typeFilter, setTypeFilter] = useState<TypeFilter>('all');
const [page, setPage] = useState(1);
const [total, setTotal] = useState(0);
const [perPage] = useState(12);
const [installing, setInstalling] = useState<string | null>(null);
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
const [error, setError] = useState<string | null>(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 (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-semibold text-foreground">Marketplace</h1>
<p className="text-sm text-muted-foreground mt-1">
Browse and install plugins and themes from the BulwarkMail extension directory
</p>
</div>
{message && (
<div className={`text-sm rounded-md px-3 py-2 ${message.type === 'success' ? 'bg-emerald-50 text-emerald-700 dark:bg-emerald-950/30 dark:text-emerald-300' : 'bg-destructive/10 text-destructive'}`}>
{message.text}
</div>
)}
{/* Search & Filters */}
<div className="flex items-center gap-3">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<input
type="text"
placeholder="Search extensions..."
value={searchInput}
onChange={(e) => 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"
/>
</div>
<div className="flex items-center gap-1 rounded-md border border-input bg-background p-0.5">
{(['all', 'plugin', 'theme'] as const).map((t) => (
<button
key={t}
onClick={() => { setTypeFilter(t); setPage(1); }}
className={`h-8 px-3 rounded text-sm font-medium transition-colors ${
typeFilter === t
? 'bg-accent text-accent-foreground'
: 'text-muted-foreground hover:text-foreground'
}`}
>
{t === 'all' ? 'All' : t === 'plugin' ? 'Plugins' : 'Themes'}
</button>
))}
</div>
</div>
{/* Error State */}
{error && (
<div className="border border-border rounded-lg p-12 text-center">
<Store className="w-10 h-10 text-muted-foreground/40 mx-auto mb-3" />
<p className="text-sm text-muted-foreground">{error}</p>
<p className="text-xs text-muted-foreground mt-1">
Start the extension directory server on the configured port
</p>
<button
onClick={fetchExtensions}
className="mt-4 inline-flex items-center gap-2 h-8 px-3 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90"
>
Retry
</button>
</div>
)}
{/* Loading State */}
{loading && !error && (
<div className="flex items-center justify-center py-12">
<Loader2 className="w-5 h-5 animate-spin text-muted-foreground" />
<span className="ml-2 text-sm text-muted-foreground">Searching extensions...</span>
</div>
)}
{/* Empty State */}
{!loading && !error && extensions.length === 0 && (
<div className="border border-border rounded-lg p-12 text-center">
<Store className="w-10 h-10 text-muted-foreground/40 mx-auto mb-3" />
<p className="text-sm text-muted-foreground">No extensions found</p>
{query && (
<p className="text-xs text-muted-foreground mt-1">
Try a different search term
</p>
)}
</div>
)}
{/* Extension Grid */}
{!loading && !error && extensions.length > 0 && (
<>
<div className="text-xs text-muted-foreground">
{total} extension{total !== 1 ? 's' : ''} found
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{extensions.map((ext) => (
<ExtensionCard
key={ext.slug}
extension={ext}
installing={installing === ext.slug}
onInstall={() => handleInstall(ext)}
/>
))}
</div>
{/* Pagination */}
{totalPages > 1 && (
<div className="flex items-center justify-center gap-2 pt-2">
<button
onClick={() => setPage(p => Math.max(1, p - 1))}
disabled={page <= 1}
className="h-8 px-3 rounded-md border border-border text-sm text-foreground hover:bg-muted disabled:opacity-50 disabled:cursor-not-allowed"
>
Previous
</button>
<span className="text-sm text-muted-foreground">
Page {page} of {totalPages}
</span>
<button
onClick={() => setPage(p => Math.min(totalPages, p + 1))}
disabled={page >= totalPages}
className="h-8 px-3 rounded-md border border-border text-sm text-foreground hover:bg-muted disabled:opacity-50 disabled:cursor-not-allowed"
>
Next
</button>
</div>
)}
</>
)}
</div>
);
}
function ExtensionCard({
extension,
installing,
onInstall,
}: {
extension: Extension;
installing: boolean;
onInstall: () => void;
}) {
const isPlugin = extension.type === 'plugin';
return (
<div className="border border-border rounded-lg overflow-hidden hover:border-ring/30 transition-colors">
<div className="p-4">
{/* Header */}
<div className="flex items-start gap-3">
<div className="w-10 h-10 rounded-md bg-muted flex items-center justify-center shrink-0">
{isPlugin ? (
<Puzzle className="w-5 h-5 text-muted-foreground" />
) : (
<SwatchBook className="w-5 h-5 text-muted-foreground" />
)}
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5">
<span className="text-sm font-medium text-foreground truncate">{extension.name}</span>
{extension.featured && (
<Star className="w-3.5 h-3.5 text-warning shrink-0 fill-warning" />
)}
</div>
<div className="flex items-center gap-1.5 mt-0.5">
<span className={`text-[10px] px-1.5 py-0.5 rounded font-medium ${
isPlugin
? 'bg-blue-100 text-blue-700 dark:bg-blue-950/30 dark:text-blue-400'
: 'bg-purple-100 text-purple-700 dark:bg-purple-950/30 dark:text-purple-400'
}`}>
{isPlugin ? (extension.pluginType || 'plugin') : 'theme'}
</span>
{extension.author && (
<span className="text-xs text-muted-foreground truncate">
by {extension.author.displayName}
</span>
)}
</div>
</div>
</div>
{/* Description */}
<p className="text-xs text-muted-foreground mt-3 line-clamp-2">
{extension.description}
</p>
{/* Tags */}
{extension.tags && extension.tags.length > 0 && (
<div className="flex flex-wrap gap-1 mt-3">
{extension.tags.slice(0, 3).map(tag => (
<span key={tag} className="text-[10px] px-1.5 py-0.5 rounded bg-muted text-muted-foreground">
{tag}
</span>
))}
</div>
)}
{/* Footer */}
<div className="flex items-center justify-between mt-4 pt-3 border-t border-border">
<div className="flex items-center gap-3 text-xs text-muted-foreground">
<span className="flex items-center gap-1">
<Download className="w-3 h-3" />
{extension.totalDownloads.toLocaleString()}
</span>
{extension.permissions && extension.permissions.length > 0 && (
<span title={extension.permissions.join(', ')}>
{extension.permissions.length} permission{extension.permissions.length !== 1 ? 's' : ''}
</span>
)}
</div>
{extension.installed ? (
<span className="inline-flex items-center gap-1 h-7 px-2.5 rounded-md bg-emerald-100 text-emerald-700 dark:bg-emerald-950/30 dark:text-emerald-400 text-xs font-medium">
<Check className="w-3 h-3" />
Installed
</span>
) : (
<button
onClick={onInstall}
disabled={installing}
className="inline-flex items-center gap-1.5 h-7 px-3 rounded-md bg-primary text-primary-foreground text-xs font-medium hover:bg-primary/90 disabled:opacity-50 transition-colors"
>
{installing ? (
<Loader2 className="w-3 h-3 animate-spin" />
) : (
<Download className="w-3 h-3" />
)}
Install
</button>
)}
</div>
</div>
</div>
);
}
+21
View File
@@ -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 (
<div className="space-y-6">
@@ -298,6 +308,17 @@ export default function AdminPluginsPage() {
</button>
</div>
<div className="px-4 py-3 flex items-center justify-between gap-4">
<div>
<span className="text-sm text-foreground">User Plugin Uploads</span>
<p className="text-xs text-muted-foreground mt-0.5">Allow users to upload plugin ZIP files in Settings</p>
</div>
<button onClick={togglePluginsUploadEnabled}
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${pluginsUploadEnabled ? '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 ${pluginsUploadEnabled ? 'translate-x-[18px]' : 'translate-x-[3px]'}`} />
</button>
</div>
{/* Force enable / disable all */}
{plugins.length > 0 && (
<div className="px-4 py-3 flex items-center justify-between gap-4">
+1 -1
View File
@@ -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<Record<keyof FeatureGates, { label: string; description: string }>> = {
sidebarAppsEnabled: { label: 'Sidebar Apps', description: 'Allow custom web apps in navigation rail' },
+232
View File
@@ -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<string, unknown>) => ({
...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<string, unknown>;
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 });
}
}
+55
View File
@@ -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 });
}
}
+81 -29
View File
@@ -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<PluginStatus, string> = {
};
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<string | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (!loaded) {
fetchPolicy();
}
initializePlugins();
}, [fetchPolicy, initializePlugins, loaded]);
if (!isFeatureEnabled('pluginsEnabled')) {
return null;
}
const pluginUploadsEnabled = isFeatureEnabled('pluginsUploadEnabled');
const handleUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
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() {
</div>
)}
{!initialized && plugins.length > 0 && (
<p className="text-xs text-muted-foreground">Syncing plugin policy and managed state...</p>
)}
{/* Upload */}
<SettingItem label="Upload Plugin" description="Install a new plugin from a .zip file">
<input
ref={fileInputRef}
type="file"
accept=".zip"
onChange={handleUpload}
className="hidden"
aria-label="Upload plugin file"
/>
<Button
variant="outline"
size="sm"
onClick={() => fileInputRef.current?.click()}
disabled={isUploading}
>
<Upload className="w-4 h-4 mr-1.5" />
{isUploading ? 'Installing...' : 'Upload .zip'}
</Button>
</SettingItem>
{pluginUploadsEnabled ? (
<SettingItem label="Upload Plugin" description="Install a new plugin from a .zip file">
<input
ref={fileInputRef}
type="file"
accept=".zip"
onChange={handleUpload}
className="hidden"
aria-label="Upload plugin file"
/>
<Button
variant="outline"
size="sm"
onClick={() => fileInputRef.current?.click()}
disabled={isUploading}
>
<Upload className="w-4 h-4 mr-1.5" />
{isUploading ? 'Installing...' : 'Upload .zip'}
</Button>
</SettingItem>
) : (
<SettingItem label="Upload Plugin" description="Install a new plugin from a .zip file">
<span className="text-xs text-muted-foreground">Disabled by administrator policy</span>
</SettingItem>
)}
</SettingsSection>
);
}
@@ -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<string, unknown>) => void;
}
function PluginCard({ plugin, isExpanded, isForceEnabled, onToggleExpand, onToggle, onUninstall, onUpdateSettings }: PluginCardProps) {
function PluginCard({ plugin, isExpanded, isForceEnabled, isManaged, controlsDisabled, onToggleExpand, onToggle, onUninstall, onUpdateSettings }: PluginCardProps) {
return (
<div className={cn(
'rounded-lg border transition-colors',
@@ -146,7 +189,12 @@ function PluginCard({ plugin, isExpanded, isForceEnabled, onToggleExpand, onTogg
</span>
{isForceEnabled && (
<span className="text-[10px] px-1.5 py-0.5 rounded-full font-medium bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400 flex items-center gap-0.5">
<Lock className="w-2.5 h-2.5" /> Admin enforced
<Lock className="w-2.5 h-2.5" /> Forced
</span>
)}
{isManaged && (
<span className="text-[10px] px-1.5 py-0.5 rounded-full font-medium bg-cyan-100 text-cyan-700 dark:bg-cyan-900/30 dark:text-cyan-400 flex items-center gap-0.5">
<Server className="w-2.5 h-2.5" /> Managed
</span>
)}
</div>
@@ -158,13 +206,17 @@ function PluginCard({ plugin, isExpanded, isForceEnabled, onToggleExpand, onTogg
</div>
<div className="flex items-center gap-2 flex-shrink-0">
<ToggleSwitch checked={plugin.enabled} onChange={onToggle} disabled={isForceEnabled} />
<ToggleSwitch checked={plugin.enabled} onChange={onToggle} disabled={controlsDisabled || isForceEnabled} />
</div>
</div>
{/* Expanded Details */}
{isExpanded && (
<div className="border-t border-border p-3 space-y-3">
{isForceEnabled && (
<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>
)}
{/* Description */}
{plugin.description && (
<p className="text-xs text-muted-foreground">{plugin.description}</p>
@@ -210,7 +262,7 @@ function PluginCard({ plugin, isExpanded, isForceEnabled, onToggleExpand, onTogg
{/* Uninstall */}
<div className="flex justify-end pt-2 border-t border-border">
<Button variant="destructive" size="sm" onClick={onUninstall}>
<Button variant="destructive" size="sm" onClick={onUninstall} disabled={controlsDisabled || isForceEnabled}>
<Trash2 className="w-3.5 h-3.5 mr-1" />
Uninstall
</Button>
+2
View File
@@ -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,
+4
View File
@@ -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<string, SettingFieldSchema>;
settings: Record<string, unknown>;
}
+250 -12
View File
@@ -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<SlotName, SlotRegistration[]> {
return slots;
}
let pluginInitializationPromise: Promise<void> | null = null;
// ─── Store Interface ─────────────────────────────────────────
interface PluginStoreState {
@@ -88,6 +91,8 @@ export const usePluginStore = create<PluginStoreState>()(
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<PluginStoreState>()(
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<PluginStoreState>()(
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<PluginStoreState>()(
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<PluginStoreState>()(
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<PluginStoreState>()(
}
)
);
// ─── 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<string> {
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<string>): void {
try {
localStorage.setItem(SERVER_MANAGED_KEY, JSON.stringify([...ids]));
} catch { /* ok */ }
}
function dedupeInstalledPlugins(plugins: InstalledPlugin[]): InstalledPlugin[] {
const byId = new Map<string, InstalledPlugin>();
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<PluginStoreState> | ((state: PluginStoreState) => Partial<PluginStoreState>)) => void,
): Promise<void> {
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<string | null> {
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;
}
}
+144
View File
@@ -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<void>;
}
const getSystemTheme = (): 'light' | 'dark' => {
@@ -52,6 +53,7 @@ const applyTheme = (theme: 'light' | 'dark') => {
};
let mediaQueryCleanup: (() => void) | null = null;
let themeSyncPromise: Promise<void> | null = null;
export const useThemeStore = create<ThemeState>()(
persist(
@@ -243,6 +245,104 @@ export const useThemeStore = create<ThemeState>()(
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<string, InstalledTheme>();
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<string | null> {
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;
}
}
+2 -1
View File
@@ -36,6 +36,7 @@
".next/dev/types/**/*.ts"
],
"exclude": [
"node_modules"
"node_modules",
"repos"
]
}