feat: add plugin/theme harness and admin dashboard
Plugin & Theme System: - Add plugin type definitions, permissions (30+), and validation constants - Add IndexedDB storage layer for plugin code, theme CSS, and previews - Add theme CSS sanitization, injection, and safety validation - Add HookBus event system with 130+ hooks across 20 domains - Add plugin ZIP extraction and manifest validation with JS security checks - Add sandboxed PluginAPI factory with scoped storage, logging, and permission gating - Add plugin loader with blob URL dynamic import and auto-disable circuit breaker - Add 3 built-in themes (Nord, Catppuccin, Solarized) - Add Zustand plugin store with install/uninstall/enable/disable lifecycle - Add PluginSlot, PluginSlotRenderer, and PluginErrorBoundary components - Add plugins and themes settings UI panels - Integrate plugin slots into email viewer, composer, navigation rail, sidebar, and context menu - Extend theme store with custom theme installation and activation Admin Dashboard: - Add admin authentication with scrypt password hashing and AES-256-GCM sessions - Add rate-limited login (5 attempts/15min per IP) - Add config manager with admin override > env var > default priority - Add settings policy system with feature gates and per-setting restrictions - Add audit logging with rotation - Add admin API routes (login, logout, config, policy, audit, password change) - Add admin UI pages (login, dashboard, config, policy, audit) - Add policy store for client-side feature gate enforcement - Wire admin password initialization into server instrumentation Tests: - Add 139 tests across 10 test files covering all plugin/theme modules
This commit is contained in:
@@ -0,0 +1,286 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useRef } from 'react';
|
||||
import { usePluginStore } from '@/stores/plugin-store';
|
||||
import { SettingsSection, SettingItem, ToggleSwitch } from './settings-section';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Upload, Trash2, AlertTriangle, Puzzle } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { toast } from '@/stores/toast-store';
|
||||
import type { InstalledPlugin, PluginStatus, SettingFieldSchema } from '@/lib/plugin-types';
|
||||
|
||||
const STATUS_COLORS: Record<PluginStatus, string> = {
|
||||
installed: 'bg-muted text-muted-foreground',
|
||||
enabled: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400',
|
||||
running: 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400',
|
||||
disabled: 'bg-muted text-muted-foreground',
|
||||
error: 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400',
|
||||
};
|
||||
|
||||
export function PluginsSettings() {
|
||||
const { plugins, installPlugin, uninstallPlugin, enablePlugin, disablePlugin, updatePluginSettings } = usePluginStore();
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const [expandedPlugin, setExpandedPlugin] = useState<string | null>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const handleUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
setIsUploading(true);
|
||||
try {
|
||||
const result = await installPlugin(file);
|
||||
if (result.success) {
|
||||
toast.success('Plugin installed');
|
||||
if (result.warnings?.length) {
|
||||
toast.warning('Plugin warnings', { message: result.warnings.join('\n') });
|
||||
}
|
||||
} else {
|
||||
toast.error('Plugin installation failed', { message: result.error });
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error('Plugin installation failed', { message: err instanceof Error ? err.message : 'Unknown error' });
|
||||
} finally {
|
||||
setIsUploading(false);
|
||||
if (fileInputRef.current) fileInputRef.current.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggle = async (plugin: InstalledPlugin) => {
|
||||
if (plugin.enabled) {
|
||||
disablePlugin(plugin.id);
|
||||
toast.info(`Plugin "${plugin.name}" disabled`);
|
||||
} else {
|
||||
await enablePlugin(plugin.id);
|
||||
toast.success(`Plugin "${plugin.name}" enabled`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUninstall = (plugin: InstalledPlugin) => {
|
||||
uninstallPlugin(plugin.id);
|
||||
toast.success(`Plugin "${plugin.name}" removed`);
|
||||
};
|
||||
|
||||
return (
|
||||
<SettingsSection title="Plugins" description="Manage installed plugins. Upload plugin .zip files to add new functionality.">
|
||||
{/* Plugin List */}
|
||||
{plugins.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<Puzzle className="w-12 h-12 text-muted-foreground/30 mb-3" />
|
||||
<p className="text-sm text-muted-foreground mb-1">No plugins installed</p>
|
||||
<p className="text-xs text-muted-foreground/70">Upload a plugin .zip file to get started</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{plugins.map(plugin => (
|
||||
<PluginCard
|
||||
key={plugin.id}
|
||||
plugin={plugin}
|
||||
isExpanded={expandedPlugin === plugin.id}
|
||||
onToggleExpand={() => setExpandedPlugin(expandedPlugin === plugin.id ? null : plugin.id)}
|
||||
onToggle={() => handleToggle(plugin)}
|
||||
onUninstall={() => handleUninstall(plugin)}
|
||||
onUpdateSettings={(settings) => updatePluginSettings(plugin.id, settings)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 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>
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Plugin Card ─────────────────────────────────────────────
|
||||
|
||||
interface PluginCardProps {
|
||||
plugin: InstalledPlugin;
|
||||
isExpanded: boolean;
|
||||
onToggleExpand: () => void;
|
||||
onToggle: () => void;
|
||||
onUninstall: () => void;
|
||||
onUpdateSettings: (settings: Record<string, unknown>) => void;
|
||||
}
|
||||
|
||||
function PluginCard({ plugin, isExpanded, onToggleExpand, onToggle, onUninstall, onUpdateSettings }: PluginCardProps) {
|
||||
return (
|
||||
<div className={cn(
|
||||
'rounded-lg border transition-colors',
|
||||
plugin.status === 'error' ? 'border-destructive/40' : 'border-border',
|
||||
)}>
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-3 p-3">
|
||||
<div className="flex-1 min-w-0 cursor-pointer" onClick={onToggleExpand}>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium text-foreground truncate">{plugin.name}</span>
|
||||
<span className={cn('text-[10px] px-1.5 py-0.5 rounded-full font-medium', STATUS_COLORS[plugin.status])}>
|
||||
{plugin.status}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-0.5">
|
||||
<span className="text-xs text-muted-foreground">{plugin.author}</span>
|
||||
<span className="text-xs text-muted-foreground/50">v{plugin.version}</span>
|
||||
<span className="text-xs text-muted-foreground/50">{plugin.type}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
<ToggleSwitch checked={plugin.enabled} onChange={onToggle} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Expanded Details */}
|
||||
{isExpanded && (
|
||||
<div className="border-t border-border p-3 space-y-3">
|
||||
{/* Description */}
|
||||
{plugin.description && (
|
||||
<p className="text-xs text-muted-foreground">{plugin.description}</p>
|
||||
)}
|
||||
|
||||
{/* Error */}
|
||||
{plugin.error && (
|
||||
<div className="flex items-start gap-2 p-2 rounded bg-destructive/10 text-destructive text-xs">
|
||||
<AlertTriangle className="w-3.5 h-3.5 flex-shrink-0 mt-0.5" />
|
||||
<span>{plugin.error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Permissions */}
|
||||
{plugin.permissions.length > 0 && (
|
||||
<div>
|
||||
<span className="text-xs font-medium text-foreground">Permissions:</span>
|
||||
<div className="flex flex-wrap gap-1 mt-1">
|
||||
{plugin.permissions.map(perm => (
|
||||
<span key={perm} className="text-[10px] px-1.5 py-0.5 rounded bg-muted text-muted-foreground">
|
||||
{perm}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Settings (auto-generated from schema) */}
|
||||
{plugin.settingsSchema && Object.keys(plugin.settingsSchema).length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<span className="text-xs font-medium text-foreground">Settings:</span>
|
||||
{Object.entries(plugin.settingsSchema).map(([key, schema]) => (
|
||||
<PluginSettingField
|
||||
key={key}
|
||||
fieldKey={key}
|
||||
schema={schema}
|
||||
value={plugin.settings[key] ?? schema.default}
|
||||
onChange={(value) => onUpdateSettings({ [key]: value })}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Uninstall */}
|
||||
<div className="flex justify-end pt-2 border-t border-border">
|
||||
<Button variant="destructive" size="sm" onClick={onUninstall}>
|
||||
<Trash2 className="w-3.5 h-3.5 mr-1" />
|
||||
Uninstall
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Auto-generated Setting Field ────────────────────────────
|
||||
|
||||
interface PluginSettingFieldProps {
|
||||
fieldKey: string;
|
||||
schema: SettingFieldSchema;
|
||||
value: unknown;
|
||||
onChange: (value: unknown) => void;
|
||||
}
|
||||
|
||||
function PluginSettingField({ schema, value, onChange }: PluginSettingFieldProps) {
|
||||
switch (schema.type) {
|
||||
case 'boolean':
|
||||
return (
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<span className="text-xs text-foreground">{schema.label}</span>
|
||||
{schema.description && <p className="text-[10px] text-muted-foreground">{schema.description}</p>}
|
||||
</div>
|
||||
<ToggleSwitch checked={value as boolean} onChange={(v) => onChange(v)} />
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'select':
|
||||
return (
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<span className="text-xs text-foreground">{schema.label}</span>
|
||||
{schema.description && <p className="text-[10px] text-muted-foreground">{schema.description}</p>}
|
||||
</div>
|
||||
<select
|
||||
value={String(value)}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
className="text-xs bg-background border border-border rounded px-2 py-1 text-foreground"
|
||||
>
|
||||
{schema.options?.map(opt => (
|
||||
<option key={opt} value={opt}>{opt}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'string':
|
||||
return (
|
||||
<div>
|
||||
<span className="text-xs text-foreground">{schema.label}</span>
|
||||
{schema.description && <p className="text-[10px] text-muted-foreground">{schema.description}</p>}
|
||||
<input
|
||||
type="text"
|
||||
value={String(value ?? '')}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
className="mt-1 w-full text-xs bg-background border border-border rounded px-2 py-1 text-foreground"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'number':
|
||||
return (
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<span className="text-xs text-foreground">{schema.label}</span>
|
||||
{schema.description && <p className="text-[10px] text-muted-foreground">{schema.description}</p>}
|
||||
</div>
|
||||
<input
|
||||
type="number"
|
||||
value={Number(value ?? schema.default ?? 0)}
|
||||
min={schema.min}
|
||||
max={schema.max}
|
||||
onChange={(e) => onChange(Number(e.target.value))}
|
||||
className="w-20 text-xs bg-background border border-border rounded px-2 py-1 text-foreground"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { ReactNode } from 'react';
|
||||
import { Lock } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface SettingsSectionProps {
|
||||
@@ -25,18 +26,22 @@ interface SettingItemProps {
|
||||
label: string;
|
||||
description?: string;
|
||||
children: ReactNode;
|
||||
locked?: boolean;
|
||||
}
|
||||
|
||||
export function SettingItem({ label, description, children }: SettingItemProps) {
|
||||
export function SettingItem({ label, description, children, locked }: SettingItemProps) {
|
||||
return (
|
||||
<div className="flex items-start justify-between py-3 border-b border-border last:border-0">
|
||||
<div className={cn("flex items-start justify-between py-3 border-b border-border last:border-0", locked && "opacity-60")}>
|
||||
<div className="flex-1 pr-4">
|
||||
<label className="text-sm font-medium text-foreground">{label}</label>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<label className="text-sm font-medium text-foreground">{label}</label>
|
||||
{locked && <Lock className="w-3 h-3 text-muted-foreground" aria-label="Managed by administrator" />}
|
||||
</div>
|
||||
{description && (
|
||||
<p className="text-xs text-muted-foreground mt-1">{description}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-shrink-0">{children}</div>
|
||||
<div className={cn("flex-shrink-0", locked && "pointer-events-none")}>{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useRef } from 'react';
|
||||
import { useThemeStore } from '@/stores/theme-store';
|
||||
import { SettingsSection, SettingItem } from './settings-section';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Upload, Trash2, Check, Palette } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { toast } from '@/stores/toast-store';
|
||||
import type { InstalledTheme } from '@/lib/plugin-types';
|
||||
|
||||
export function ThemesSettings() {
|
||||
const { installedThemes, activeThemeId, installTheme, uninstallTheme, activateTheme } = useThemeStore();
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const handleUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
setIsUploading(true);
|
||||
try {
|
||||
const result = await installTheme(file);
|
||||
if (result.success) {
|
||||
toast.success('Theme installed');
|
||||
if (result.warnings?.length) {
|
||||
toast.warning('Theme warnings', { message: result.warnings.join('\n') });
|
||||
}
|
||||
} else {
|
||||
toast.error('Theme installation failed', { message: result.error });
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error('Theme installation failed', { message: err instanceof Error ? err.message : 'Unknown error' });
|
||||
} finally {
|
||||
setIsUploading(false);
|
||||
// Reset file input
|
||||
if (fileInputRef.current) fileInputRef.current.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
const handleActivate = (id: string | null) => {
|
||||
activateTheme(id);
|
||||
toast.success(id ? 'Theme activated' : 'Default theme restored');
|
||||
};
|
||||
|
||||
const handleUninstall = (theme: InstalledTheme) => {
|
||||
if (theme.builtIn) return;
|
||||
uninstallTheme(theme.id);
|
||||
toast.success('Theme removed');
|
||||
};
|
||||
|
||||
return (
|
||||
<SettingsSection title="Themes" description="Customize the appearance with color themes. Upload .zip theme files or activate built-in presets.">
|
||||
{/* Theme Grid */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3">
|
||||
{/* Default theme card */}
|
||||
<ThemeCard
|
||||
name="Default"
|
||||
author="Bulwark"
|
||||
isActive={activeThemeId === null}
|
||||
isBuiltIn
|
||||
onActivate={() => handleActivate(null)}
|
||||
/>
|
||||
|
||||
{/* Installed themes */}
|
||||
{installedThemes.map(theme => (
|
||||
<ThemeCard
|
||||
key={theme.id}
|
||||
name={theme.name}
|
||||
author={theme.author}
|
||||
preview={theme.preview}
|
||||
isActive={activeThemeId === theme.id}
|
||||
isBuiltIn={theme.builtIn}
|
||||
variants={theme.variants}
|
||||
onActivate={() => handleActivate(theme.id)}
|
||||
onRemove={!theme.builtIn ? () => handleUninstall(theme) : undefined}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Upload */}
|
||||
<SettingItem label="Upload Theme" description="Install a custom theme from a .zip file containing manifest.json and theme.css">
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".zip"
|
||||
onChange={handleUpload}
|
||||
className="hidden"
|
||||
aria-label="Upload theme 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>
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Theme Card ──────────────────────────────────────────────
|
||||
|
||||
interface ThemeCardProps {
|
||||
name: string;
|
||||
author: string;
|
||||
preview?: string;
|
||||
isActive: boolean;
|
||||
isBuiltIn: boolean;
|
||||
variants?: ('light' | 'dark')[];
|
||||
onActivate: () => void;
|
||||
onRemove?: () => void;
|
||||
}
|
||||
|
||||
function ThemeCard({ name, author, preview, isActive, variants, onActivate, onRemove }: ThemeCardProps) {
|
||||
return (
|
||||
<button
|
||||
onClick={onActivate}
|
||||
className={cn(
|
||||
'relative flex flex-col items-center p-3 rounded-xl border-2 transition-all text-left w-full',
|
||||
isActive
|
||||
? 'border-primary bg-primary/5 ring-1 ring-primary/20'
|
||||
: 'border-border hover:border-primary/40 bg-card'
|
||||
)}
|
||||
>
|
||||
{/* Preview / Placeholder */}
|
||||
<div className="w-full aspect-[16/10] rounded-lg mb-2 overflow-hidden bg-muted flex items-center justify-center">
|
||||
{preview ? (
|
||||
<img src={preview} alt={name} className="w-full h-full object-cover" />
|
||||
) : (
|
||||
<Palette className="w-8 h-8 text-muted-foreground/40" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Info */}
|
||||
<div className="w-full">
|
||||
<div className="flex items-center justify-between gap-1">
|
||||
<span className="text-sm font-medium text-foreground truncate">{name}</span>
|
||||
{isActive && <Check className="w-4 h-4 text-primary flex-shrink-0" />}
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground truncate block">{author}</span>
|
||||
{variants && (
|
||||
<div className="flex gap-1 mt-1">
|
||||
{variants.map(v => (
|
||||
<span key={v} className="text-[10px] px-1.5 py-0.5 rounded bg-muted text-muted-foreground">
|
||||
{v}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Remove button */}
|
||||
{onRemove && !isActive && (
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); onRemove(); }}
|
||||
className="absolute top-2 right-2 p-1 rounded-md hover:bg-destructive/10 text-muted-foreground hover:text-destructive transition-colors"
|
||||
title="Remove theme"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user