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
|
# URL for the company website link on the login page
|
||||||
LOGIN_WEBSITE_URL=https://bulwarkmail.org
|
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)
|
# 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 { Button } from "@/components/ui/button";
|
||||||
import { useConfig } from "@/hooks/use-config";
|
import { useConfig } from "@/hooks/use-config";
|
||||||
import { usePluginStore } from "@/stores/plugin-store";
|
import { usePluginStore } from "@/stores/plugin-store";
|
||||||
|
import { useThemeStore } from "@/stores/theme-store";
|
||||||
|
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
@@ -286,8 +287,10 @@ export default function Home() {
|
|||||||
}, [checkAuth]);
|
}, [checkAuth]);
|
||||||
|
|
||||||
// Initialize plugins on mount (re-activates enabled plugins after refresh)
|
// Initialize plugins on mount (re-activates enabled plugins after refresh)
|
||||||
|
// Also syncs server-managed plugins and themes to the client
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
usePluginStore.getState().initializePlugins();
|
usePluginStore.getState().initializePlugins();
|
||||||
|
useThemeStore.getState().syncServerThemes();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Hydrate persisted column widths from localStorage
|
// Hydrate persisted column widths from localStorage
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
BookUser,
|
BookUser,
|
||||||
HardDrive,
|
HardDrive,
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
|
Store,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { useConfig } from '@/hooks/use-config';
|
import { useConfig } from '@/hooks/use-config';
|
||||||
@@ -47,6 +48,7 @@ const NAV_GROUPS = [
|
|||||||
items: [
|
items: [
|
||||||
{ href: '/admin/plugins', label: 'Plugins', icon: Puzzle },
|
{ href: '/admin/plugins', label: 'Plugins', icon: Puzzle },
|
||||||
{ href: '/admin/themes', label: 'Themes', icon: SwatchBook },
|
{ 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);
|
setMessage(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function togglePluginsUploadEnabled() {
|
||||||
|
setPolicy(prev => ({
|
||||||
|
...prev,
|
||||||
|
features: { ...prev.features, pluginsUploadEnabled: !prev.features.pluginsUploadEnabled },
|
||||||
|
}));
|
||||||
|
setPolicyDirty(true);
|
||||||
|
setMessage(null);
|
||||||
|
}
|
||||||
|
|
||||||
async function handleSavePolicy() {
|
async function handleSavePolicy() {
|
||||||
setSavingPolicy(true);
|
setSavingPolicy(true);
|
||||||
setMessage(null);
|
setMessage(null);
|
||||||
@@ -237,6 +246,7 @@ export default function AdminPluginsPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const pluginsEnabled = policy.features.pluginsEnabled ?? true;
|
const pluginsEnabled = policy.features.pluginsEnabled ?? true;
|
||||||
|
const pluginsUploadEnabled = policy.features.pluginsUploadEnabled ?? true;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
@@ -298,6 +308,17 @@ export default function AdminPluginsPage() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="px-4 py-3 flex items-center justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<span className="text-sm text-foreground">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 */}
|
{/* Force enable / disable all */}
|
||||||
{plugins.length > 0 && (
|
{plugins.length > 0 && (
|
||||||
<div className="px-4 py-3 flex items-center justify-between gap-4">
|
<div className="px-4 py-3 flex items-center justify-between gap-4">
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import type { SettingsPolicy, FeatureGates } from '@/lib/admin/types';
|
|||||||
import { DEFAULT_FEATURE_GATES, DEFAULT_POLICY } 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)
|
// 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 }>> = {
|
const FEATURE_GATE_LABELS: Partial<Record<keyof FeatureGates, { label: string; description: string }>> = {
|
||||||
sidebarAppsEnabled: { label: 'Sidebar Apps', description: 'Allow custom web apps in navigation rail' },
|
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';
|
'use client';
|
||||||
|
|
||||||
import { useState, useRef } from 'react';
|
import { useState, useRef, useEffect } from 'react';
|
||||||
import { usePluginStore } from '@/stores/plugin-store';
|
import { usePluginStore } from '@/stores/plugin-store';
|
||||||
import { usePolicyStore } from '@/stores/policy-store';
|
import { usePolicyStore } from '@/stores/policy-store';
|
||||||
import { SettingsSection, SettingItem, ToggleSwitch } from './settings-section';
|
import { SettingsSection, SettingItem, ToggleSwitch } from './settings-section';
|
||||||
import { cn } from '@/lib/utils';
|
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 { Button } from '@/components/ui/button';
|
||||||
import { toast } from '@/stores/toast-store';
|
import { toast } from '@/stores/toast-store';
|
||||||
import type { InstalledPlugin, PluginStatus, SettingFieldSchema } from '@/lib/plugin-types';
|
import type { InstalledPlugin, PluginStatus, SettingFieldSchema } from '@/lib/plugin-types';
|
||||||
@@ -19,17 +19,31 @@ const STATUS_COLORS: Record<PluginStatus, string> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export function PluginsSettings() {
|
export function PluginsSettings() {
|
||||||
const { plugins, installPlugin, uninstallPlugin, enablePlugin, disablePlugin, updatePluginSettings } = usePluginStore();
|
const { plugins, installPlugin, uninstallPlugin, enablePlugin, disablePlugin, updatePluginSettings, initializePlugins, initialized } = usePluginStore();
|
||||||
const { isFeatureEnabled, isPluginForceEnabled } = usePolicyStore();
|
const { isFeatureEnabled, isPluginForceEnabled, fetchPolicy, loaded } = usePolicyStore();
|
||||||
const [isUploading, setIsUploading] = useState(false);
|
const [isUploading, setIsUploading] = useState(false);
|
||||||
const [expandedPlugin, setExpandedPlugin] = useState<string | null>(null);
|
const [expandedPlugin, setExpandedPlugin] = useState<string | null>(null);
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!loaded) {
|
||||||
|
fetchPolicy();
|
||||||
|
}
|
||||||
|
initializePlugins();
|
||||||
|
}, [fetchPolicy, initializePlugins, loaded]);
|
||||||
|
|
||||||
if (!isFeatureEnabled('pluginsEnabled')) {
|
if (!isFeatureEnabled('pluginsEnabled')) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const pluginUploadsEnabled = isFeatureEnabled('pluginsUploadEnabled');
|
||||||
|
|
||||||
const handleUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
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];
|
const file = e.target.files?.[0];
|
||||||
if (!file) return;
|
if (!file) return;
|
||||||
|
|
||||||
@@ -53,7 +67,14 @@ export function PluginsSettings() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleToggle = async (plugin: InstalledPlugin) => {
|
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) {
|
if (plugin.enabled) {
|
||||||
disablePlugin(plugin.id);
|
disablePlugin(plugin.id);
|
||||||
toast.info(`Plugin "${plugin.name}" disabled`);
|
toast.info(`Plugin "${plugin.name}" disabled`);
|
||||||
@@ -64,6 +85,14 @@ export function PluginsSettings() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleUninstall = (plugin: InstalledPlugin) => {
|
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);
|
uninstallPlugin(plugin.id);
|
||||||
toast.success(`Plugin "${plugin.name}" removed`);
|
toast.success(`Plugin "${plugin.name}" removed`);
|
||||||
};
|
};
|
||||||
@@ -84,7 +113,9 @@ export function PluginsSettings() {
|
|||||||
key={plugin.id}
|
key={plugin.id}
|
||||||
plugin={plugin}
|
plugin={plugin}
|
||||||
isExpanded={expandedPlugin === plugin.id}
|
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)}
|
onToggleExpand={() => setExpandedPlugin(expandedPlugin === plugin.id ? null : plugin.id)}
|
||||||
onToggle={() => handleToggle(plugin)}
|
onToggle={() => handleToggle(plugin)}
|
||||||
onUninstall={() => handleUninstall(plugin)}
|
onUninstall={() => handleUninstall(plugin)}
|
||||||
@@ -94,26 +125,36 @@ export function PluginsSettings() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{!initialized && plugins.length > 0 && (
|
||||||
|
<p className="text-xs text-muted-foreground">Syncing plugin policy and managed state...</p>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Upload */}
|
{/* Upload */}
|
||||||
<SettingItem label="Upload Plugin" description="Install a new plugin from a .zip file">
|
{pluginUploadsEnabled ? (
|
||||||
<input
|
<SettingItem label="Upload Plugin" description="Install a new plugin from a .zip file">
|
||||||
ref={fileInputRef}
|
<input
|
||||||
type="file"
|
ref={fileInputRef}
|
||||||
accept=".zip"
|
type="file"
|
||||||
onChange={handleUpload}
|
accept=".zip"
|
||||||
className="hidden"
|
onChange={handleUpload}
|
||||||
aria-label="Upload plugin file"
|
className="hidden"
|
||||||
/>
|
aria-label="Upload plugin file"
|
||||||
<Button
|
/>
|
||||||
variant="outline"
|
<Button
|
||||||
size="sm"
|
variant="outline"
|
||||||
onClick={() => fileInputRef.current?.click()}
|
size="sm"
|
||||||
disabled={isUploading}
|
onClick={() => fileInputRef.current?.click()}
|
||||||
>
|
disabled={isUploading}
|
||||||
<Upload className="w-4 h-4 mr-1.5" />
|
>
|
||||||
{isUploading ? 'Installing...' : 'Upload .zip'}
|
<Upload className="w-4 h-4 mr-1.5" />
|
||||||
</Button>
|
{isUploading ? 'Installing...' : 'Upload .zip'}
|
||||||
</SettingItem>
|
</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>
|
</SettingsSection>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -124,13 +165,15 @@ interface PluginCardProps {
|
|||||||
plugin: InstalledPlugin;
|
plugin: InstalledPlugin;
|
||||||
isExpanded: boolean;
|
isExpanded: boolean;
|
||||||
isForceEnabled: boolean;
|
isForceEnabled: boolean;
|
||||||
|
isManaged: boolean;
|
||||||
|
controlsDisabled: boolean;
|
||||||
onToggleExpand: () => void;
|
onToggleExpand: () => void;
|
||||||
onToggle: () => void;
|
onToggle: () => void;
|
||||||
onUninstall: () => void;
|
onUninstall: () => void;
|
||||||
onUpdateSettings: (settings: Record<string, unknown>) => 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 (
|
return (
|
||||||
<div className={cn(
|
<div className={cn(
|
||||||
'rounded-lg border transition-colors',
|
'rounded-lg border transition-colors',
|
||||||
@@ -146,7 +189,12 @@ function PluginCard({ plugin, isExpanded, isForceEnabled, onToggleExpand, onTogg
|
|||||||
</span>
|
</span>
|
||||||
{isForceEnabled && (
|
{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">
|
<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>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -158,13 +206,17 @@ function PluginCard({ plugin, isExpanded, isForceEnabled, onToggleExpand, onTogg
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2 flex-shrink-0">
|
<div className="flex items-center gap-2 flex-shrink-0">
|
||||||
<ToggleSwitch checked={plugin.enabled} onChange={onToggle} disabled={isForceEnabled} />
|
<ToggleSwitch checked={plugin.enabled} onChange={onToggle} disabled={controlsDisabled || isForceEnabled} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Expanded Details */}
|
{/* Expanded Details */}
|
||||||
{isExpanded && (
|
{isExpanded && (
|
||||||
<div className="border-t border-border p-3 space-y-3">
|
<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 */}
|
{/* Description */}
|
||||||
{plugin.description && (
|
{plugin.description && (
|
||||||
<p className="text-xs text-muted-foreground">{plugin.description}</p>
|
<p className="text-xs text-muted-foreground">{plugin.description}</p>
|
||||||
@@ -210,7 +262,7 @@ function PluginCard({ plugin, isExpanded, isForceEnabled, onToggleExpand, onTogg
|
|||||||
|
|
||||||
{/* Uninstall */}
|
{/* Uninstall */}
|
||||||
<div className="flex justify-end pt-2 border-t border-border">
|
<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" />
|
<Trash2 className="w-3.5 h-3.5 mr-1" />
|
||||||
Uninstall
|
Uninstall
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ export interface SettingRestriction {
|
|||||||
|
|
||||||
export interface FeatureGates {
|
export interface FeatureGates {
|
||||||
pluginsEnabled: boolean;
|
pluginsEnabled: boolean;
|
||||||
|
pluginsUploadEnabled: boolean;
|
||||||
themesEnabled: boolean;
|
themesEnabled: boolean;
|
||||||
sidebarAppsEnabled: boolean;
|
sidebarAppsEnabled: boolean;
|
||||||
userThemesEnabled: boolean;
|
userThemesEnabled: boolean;
|
||||||
@@ -40,6 +41,7 @@ export interface FeatureGates {
|
|||||||
|
|
||||||
export const DEFAULT_FEATURE_GATES: FeatureGates = {
|
export const DEFAULT_FEATURE_GATES: FeatureGates = {
|
||||||
pluginsEnabled: true,
|
pluginsEnabled: true,
|
||||||
|
pluginsUploadEnabled: true,
|
||||||
themesEnabled: true,
|
themesEnabled: true,
|
||||||
sidebarAppsEnabled: true,
|
sidebarAppsEnabled: true,
|
||||||
userThemesEnabled: true,
|
userThemesEnabled: true,
|
||||||
|
|||||||
@@ -73,6 +73,10 @@ export interface InstalledPlugin {
|
|||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
status: PluginStatus;
|
status: PluginStatus;
|
||||||
error?: string;
|
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>;
|
settingsSchema?: Record<string, SettingFieldSchema>;
|
||||||
settings: Record<string, unknown>;
|
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 { loadPlugin, deactivatePlugin, setPluginStoreAccessor, setupAutoDisable } from '@/lib/plugin-loader';
|
||||||
import { setSlotRegistrationBridge } from '@/lib/plugin-api';
|
import { setSlotRegistrationBridge } from '@/lib/plugin-api';
|
||||||
import { removeAllPluginHooks } from '@/lib/plugin-hooks';
|
import { removeAllPluginHooks } from '@/lib/plugin-hooks';
|
||||||
|
import { usePolicyStore } from '@/stores/policy-store';
|
||||||
|
|
||||||
// ─── Slot State ──────────────────────────────────────────────
|
// ─── Slot State ──────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -30,6 +31,8 @@ function emptySlots(): Record<SlotName, SlotRegistration[]> {
|
|||||||
return slots;
|
return slots;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let pluginInitializationPromise: Promise<void> | null = null;
|
||||||
|
|
||||||
// ─── Store Interface ─────────────────────────────────────────
|
// ─── Store Interface ─────────────────────────────────────────
|
||||||
|
|
||||||
interface PluginStoreState {
|
interface PluginStoreState {
|
||||||
@@ -88,6 +91,8 @@ export const usePluginStore = create<PluginStoreState>()(
|
|||||||
entrypoint: manifest.entrypoint,
|
entrypoint: manifest.entrypoint,
|
||||||
enabled: false, // Start disabled, user must enable
|
enabled: false, // Start disabled, user must enable
|
||||||
status: 'installed',
|
status: 'installed',
|
||||||
|
managed: false,
|
||||||
|
forceEnabled: false,
|
||||||
settings: existing?.settings ?? {},
|
settings: existing?.settings ?? {},
|
||||||
settingsSchema: manifest.settingsSchema,
|
settingsSchema: manifest.settingsSchema,
|
||||||
};
|
};
|
||||||
@@ -108,6 +113,8 @@ export const usePluginStore = create<PluginStoreState>()(
|
|||||||
const { plugins } = get();
|
const { plugins } = get();
|
||||||
const plugin = plugins.find(p => p.id === id);
|
const plugin = plugins.find(p => p.id === id);
|
||||||
if (!plugin) return;
|
if (!plugin) return;
|
||||||
|
const forceEnabledByPolicy = usePolicyStore.getState().isPluginForceEnabled(id);
|
||||||
|
if (plugin.forceEnabled || forceEnabledByPolicy) return;
|
||||||
|
|
||||||
// Deactivate if running
|
// Deactivate if running
|
||||||
deactivatePlugin(id);
|
deactivatePlugin(id);
|
||||||
@@ -155,6 +162,11 @@ export const usePluginStore = create<PluginStoreState>()(
|
|||||||
|
|
||||||
disablePlugin: (id: string) => {
|
disablePlugin: (id: string) => {
|
||||||
const { plugins } = get();
|
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);
|
deactivatePlugin(id);
|
||||||
|
|
||||||
set({
|
set({
|
||||||
@@ -207,20 +219,42 @@ export const usePluginStore = create<PluginStoreState>()(
|
|||||||
initializePlugins: async () => {
|
initializePlugins: async () => {
|
||||||
if (get().initialized) return;
|
if (get().initialized) return;
|
||||||
|
|
||||||
// Wire up bridges
|
if (pluginInitializationPromise) {
|
||||||
setPluginStoreAccessor({
|
await pluginInitializationPromise;
|
||||||
setPluginStatus: get().setPluginStatus,
|
return;
|
||||||
});
|
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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: () => {
|
onRehydrateStorage: () => {
|
||||||
return (state) => {
|
return (state) => {
|
||||||
if (state) {
|
if (state) {
|
||||||
|
state.plugins = markServerManagedPlugins(state.plugins);
|
||||||
|
state.plugins = dedupeInstalledPlugins(state.plugins);
|
||||||
// Ensure slots are initialized after rehydration
|
// Ensure slots are initialized after rehydration
|
||||||
state.slots = emptySlots();
|
state.slots = emptySlots();
|
||||||
state.initialized = false;
|
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[] }>;
|
installTheme: (file: File) => Promise<{ success: boolean; error?: string; warnings?: string[] }>;
|
||||||
uninstallTheme: (id: string) => void;
|
uninstallTheme: (id: string) => void;
|
||||||
activateTheme: (id: string | null) => void;
|
activateTheme: (id: string | null) => void;
|
||||||
|
syncServerThemes: () => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const getSystemTheme = (): 'light' | 'dark' => {
|
const getSystemTheme = (): 'light' | 'dark' => {
|
||||||
@@ -52,6 +53,7 @@ const applyTheme = (theme: 'light' | 'dark') => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
let mediaQueryCleanup: (() => void) | null = null;
|
let mediaQueryCleanup: (() => void) | null = null;
|
||||||
|
let themeSyncPromise: Promise<void> | null = null;
|
||||||
|
|
||||||
export const useThemeStore = create<ThemeState>()(
|
export const useThemeStore = create<ThemeState>()(
|
||||||
persist(
|
persist(
|
||||||
@@ -243,6 +245,104 @@ export const useThemeStore = create<ThemeState>()(
|
|||||||
applyCustomThemeCSS(theme, resolvedTheme);
|
applyCustomThemeCSS(theme, resolvedTheme);
|
||||||
set({ activeThemeId: id });
|
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',
|
name: 'theme-storage',
|
||||||
@@ -285,3 +385,47 @@ function applyCustomThemeCSS(theme: InstalledTheme, resolvedTheme: 'light' | 'da
|
|||||||
}
|
}
|
||||||
injectThemeCSS(theme.css);
|
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"
|
".next/dev/types/**/*.ts"
|
||||||
],
|
],
|
||||||
"exclude": [
|
"exclude": [
|
||||||
"node_modules"
|
"node_modules",
|
||||||
|
"repos"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user