feat: enforce forced/managed plugins and policy. Split user upload permission
This commit is contained in:
@@ -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)
|
||||
# =============================================================================
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -36,6 +36,7 @@
|
||||
".next/dev/types/**/*.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules"
|
||||
"node_modules",
|
||||
"repos"
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user