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,244 @@
|
||||
// Plugin store — manages installed plugins, slot registrations, and lifecycle
|
||||
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
import type {
|
||||
InstalledPlugin,
|
||||
PluginStatus,
|
||||
SlotName,
|
||||
SlotRegistration,
|
||||
Disposable,
|
||||
} from '@/lib/plugin-types';
|
||||
import { pluginStorage } from '@/lib/plugin-storage';
|
||||
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';
|
||||
|
||||
// ─── Slot State ──────────────────────────────────────────────
|
||||
|
||||
const SLOT_NAMES: SlotName[] = [
|
||||
'toolbar-actions', 'email-banner', 'email-footer', 'composer-toolbar',
|
||||
'sidebar-widget', 'settings-section', 'context-menu-email', 'navigation-rail-bottom',
|
||||
];
|
||||
|
||||
function emptySlots(): Record<SlotName, SlotRegistration[]> {
|
||||
const slots = {} as Record<SlotName, SlotRegistration[]>;
|
||||
for (const name of SLOT_NAMES) {
|
||||
slots[name] = [];
|
||||
}
|
||||
return slots;
|
||||
}
|
||||
|
||||
// ─── Store Interface ─────────────────────────────────────────
|
||||
|
||||
interface PluginStoreState {
|
||||
plugins: InstalledPlugin[];
|
||||
slots: Record<SlotName, SlotRegistration[]>;
|
||||
initialized: boolean;
|
||||
|
||||
// Management
|
||||
installPlugin: (file: File) => Promise<{ success: boolean; error?: string; warnings?: string[] }>;
|
||||
uninstallPlugin: (id: string) => void;
|
||||
enablePlugin: (id: string) => Promise<void>;
|
||||
disablePlugin: (id: string) => void;
|
||||
updatePluginSettings: (id: string, settings: Record<string, unknown>) => void;
|
||||
|
||||
// Runtime (called by plugin loader / API bridge)
|
||||
registerSlot: (slotName: SlotName, registration: SlotRegistration) => Disposable;
|
||||
setPluginStatus: (id: string, status: PluginStatus, error?: string) => void;
|
||||
|
||||
// Init
|
||||
initializePlugins: () => Promise<void>;
|
||||
}
|
||||
|
||||
// ─── Store ───────────────────────────────────────────────────
|
||||
|
||||
export const usePluginStore = create<PluginStoreState>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
plugins: [],
|
||||
slots: emptySlots(),
|
||||
initialized: false,
|
||||
|
||||
installPlugin: async (file: File) => {
|
||||
const result = await extractPlugin(file);
|
||||
if (!result.valid || !result.manifest) {
|
||||
return { success: false, error: result.errors.join('; '), warnings: result.warnings };
|
||||
}
|
||||
|
||||
const { manifest, code } = result;
|
||||
const { plugins } = get();
|
||||
|
||||
// Check for duplicate
|
||||
const existing = plugins.find(p => p.id === manifest.id);
|
||||
if (existing) {
|
||||
// Update: deactivate old, replace
|
||||
deactivatePlugin(manifest.id);
|
||||
}
|
||||
|
||||
const plugin: InstalledPlugin = {
|
||||
id: manifest.id,
|
||||
name: manifest.name,
|
||||
version: manifest.version,
|
||||
author: manifest.author,
|
||||
description: manifest.description,
|
||||
type: manifest.type,
|
||||
permissions: manifest.permissions,
|
||||
entrypoint: manifest.entrypoint,
|
||||
enabled: false, // Start disabled, user must enable
|
||||
status: 'installed',
|
||||
settings: existing?.settings ?? {},
|
||||
settingsSchema: manifest.settingsSchema,
|
||||
};
|
||||
|
||||
// Save code to IndexedDB
|
||||
await pluginStorage.saveCode(manifest.id, code);
|
||||
|
||||
if (existing) {
|
||||
set({ plugins: plugins.map(p => p.id === manifest.id ? plugin : p) });
|
||||
} else {
|
||||
set({ plugins: [...plugins, plugin] });
|
||||
}
|
||||
|
||||
return { success: true, warnings: result.warnings };
|
||||
},
|
||||
|
||||
uninstallPlugin: (id: string) => {
|
||||
const { plugins } = get();
|
||||
const plugin = plugins.find(p => p.id === id);
|
||||
if (!plugin) return;
|
||||
|
||||
// Deactivate if running
|
||||
deactivatePlugin(id);
|
||||
removeAllPluginHooks(id);
|
||||
|
||||
// Clean up storage
|
||||
pluginStorage.deleteCode(id);
|
||||
pluginStorage.deletePreview(id);
|
||||
|
||||
// Remove plugin-scoped localStorage entries
|
||||
if (typeof window !== 'undefined') {
|
||||
const prefix = `plugin:${id}:`;
|
||||
const keysToRemove: string[] = [];
|
||||
for (let i = 0; i < localStorage.length; i++) {
|
||||
const key = localStorage.key(i);
|
||||
if (key?.startsWith(prefix)) keysToRemove.push(key);
|
||||
}
|
||||
keysToRemove.forEach(k => localStorage.removeItem(k));
|
||||
}
|
||||
|
||||
set({ plugins: plugins.filter(p => p.id !== id) });
|
||||
},
|
||||
|
||||
enablePlugin: async (id: string) => {
|
||||
const { plugins } = get();
|
||||
const plugin = plugins.find(p => p.id === id);
|
||||
if (!plugin) return;
|
||||
|
||||
set({
|
||||
plugins: plugins.map(p =>
|
||||
p.id === id ? { ...p, enabled: true, status: 'enabled' as PluginStatus, error: undefined } : p
|
||||
),
|
||||
});
|
||||
|
||||
// Load it immediately
|
||||
const updatedPlugin = get().plugins.find(p => p.id === id);
|
||||
if (updatedPlugin) {
|
||||
await loadPlugin(updatedPlugin);
|
||||
}
|
||||
},
|
||||
|
||||
disablePlugin: (id: string) => {
|
||||
const { plugins } = get();
|
||||
deactivatePlugin(id);
|
||||
|
||||
set({
|
||||
plugins: plugins.map(p =>
|
||||
p.id === id ? { ...p, enabled: false, status: 'disabled' as PluginStatus, error: undefined } : p
|
||||
),
|
||||
});
|
||||
},
|
||||
|
||||
updatePluginSettings: (id: string, settings: Record<string, unknown>) => {
|
||||
const { plugins } = get();
|
||||
set({
|
||||
plugins: plugins.map(p =>
|
||||
p.id === id ? { ...p, settings: { ...p.settings, ...settings } } : p
|
||||
),
|
||||
});
|
||||
},
|
||||
|
||||
registerSlot: (slotName: SlotName, registration: SlotRegistration): Disposable => {
|
||||
set(state => ({
|
||||
slots: {
|
||||
...state.slots,
|
||||
[slotName]: [
|
||||
...state.slots[slotName],
|
||||
registration,
|
||||
].sort((a, b) => a.order - b.order),
|
||||
},
|
||||
}));
|
||||
|
||||
return {
|
||||
dispose: () => {
|
||||
set(state => ({
|
||||
slots: {
|
||||
...state.slots,
|
||||
[slotName]: state.slots[slotName].filter(r => r !== registration),
|
||||
},
|
||||
}));
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
setPluginStatus: (id: string, status: PluginStatus, error?: string) => {
|
||||
set(state => ({
|
||||
plugins: state.plugins.map(p =>
|
||||
p.id === id ? { ...p, status, error } : p
|
||||
),
|
||||
}));
|
||||
},
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
set({ initialized: true });
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: 'plugin-storage',
|
||||
partialize: (state) => ({
|
||||
plugins: state.plugins.map(p => ({
|
||||
...p,
|
||||
// Reset runtime state on persist
|
||||
status: p.enabled ? 'enabled' : 'installed',
|
||||
error: undefined,
|
||||
})),
|
||||
// Don't persist slots — they are runtime-only, rebuilt on load
|
||||
}),
|
||||
onRehydrateStorage: () => {
|
||||
return (state) => {
|
||||
if (state) {
|
||||
// Ensure slots are initialized after rehydration
|
||||
state.slots = emptySlots();
|
||||
state.initialized = false;
|
||||
}
|
||||
};
|
||||
},
|
||||
}
|
||||
)
|
||||
);
|
||||
@@ -0,0 +1,55 @@
|
||||
import { create } from 'zustand';
|
||||
import type { SettingsPolicy, FeatureGates, SettingRestriction } from '@/lib/admin/types';
|
||||
import { DEFAULT_POLICY } from '@/lib/admin/types';
|
||||
|
||||
interface PolicyState {
|
||||
policy: SettingsPolicy;
|
||||
loaded: boolean;
|
||||
fetchPolicy: () => Promise<void>;
|
||||
isSettingLocked: (key: string) => boolean;
|
||||
isSettingHidden: (key: string) => boolean;
|
||||
isFeatureEnabled: (feature: keyof FeatureGates) => boolean;
|
||||
getRestriction: (key: string) => SettingRestriction | undefined;
|
||||
getEffectiveDefault: (key: string) => unknown;
|
||||
}
|
||||
|
||||
export const usePolicyStore = create<PolicyState>()((set, get) => ({
|
||||
policy: { ...DEFAULT_POLICY },
|
||||
loaded: false,
|
||||
|
||||
fetchPolicy: async () => {
|
||||
try {
|
||||
const res = await fetch('/api/admin/policy');
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
set({ policy: data, loaded: true });
|
||||
} else {
|
||||
set({ loaded: true });
|
||||
}
|
||||
} catch {
|
||||
set({ loaded: true });
|
||||
}
|
||||
},
|
||||
|
||||
isSettingLocked: (key) => {
|
||||
const r = get().policy.restrictions[key];
|
||||
return r?.locked === true;
|
||||
},
|
||||
|
||||
isSettingHidden: (key) => {
|
||||
const r = get().policy.restrictions[key];
|
||||
return r?.hidden === true;
|
||||
},
|
||||
|
||||
isFeatureEnabled: (feature) => {
|
||||
return get().policy.features[feature] ?? true;
|
||||
},
|
||||
|
||||
getRestriction: (key) => {
|
||||
return get().policy.restrictions[key];
|
||||
},
|
||||
|
||||
getEffectiveDefault: (key) => {
|
||||
return get().policy.defaults[key];
|
||||
},
|
||||
}));
|
||||
+173
-4
@@ -1,5 +1,10 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
import type { InstalledTheme, ThemeVariant } from '@/lib/plugin-types';
|
||||
import { pluginStorage } from '@/lib/plugin-storage';
|
||||
import { injectThemeCSS, removeThemeCSS, sanitizeThemeCSS } from '@/lib/theme-loader';
|
||||
import { extractTheme } from '@/lib/plugin-validator';
|
||||
import { BUILTIN_THEMES } from '@/lib/builtin-themes';
|
||||
|
||||
type Theme = 'light' | 'dark' | 'system';
|
||||
|
||||
@@ -7,9 +12,19 @@ interface ThemeState {
|
||||
theme: Theme;
|
||||
resolvedTheme: 'light' | 'dark';
|
||||
hydrated: boolean;
|
||||
|
||||
// Custom theme system
|
||||
installedThemes: InstalledTheme[];
|
||||
activeThemeId: string | null; // null = built-in default
|
||||
|
||||
setTheme: (theme: Theme) => void;
|
||||
toggleTheme: () => void;
|
||||
initializeTheme: () => void;
|
||||
|
||||
// Custom theme management
|
||||
installTheme: (file: File) => Promise<{ success: boolean; error?: string; warnings?: string[] }>;
|
||||
uninstallTheme: (id: string) => void;
|
||||
activateTheme: (id: string | null) => void;
|
||||
}
|
||||
|
||||
const getSystemTheme = (): 'light' | 'dark' => {
|
||||
@@ -43,11 +58,19 @@ export const useThemeStore = create<ThemeState>()(
|
||||
theme: 'system',
|
||||
resolvedTheme: 'light',
|
||||
hydrated: false,
|
||||
installedThemes: [...BUILTIN_THEMES],
|
||||
activeThemeId: null,
|
||||
|
||||
setTheme: (theme) => {
|
||||
const resolvedTheme = theme === 'system' ? getSystemTheme() : theme;
|
||||
applyTheme(resolvedTheme);
|
||||
set({ theme, resolvedTheme });
|
||||
// Re-apply active custom theme for new mode
|
||||
const { activeThemeId, installedThemes } = get();
|
||||
if (activeThemeId) {
|
||||
const t = installedThemes.find(t => t.id === activeThemeId);
|
||||
if (t) applyCustomThemeCSS(t, resolvedTheme);
|
||||
}
|
||||
},
|
||||
|
||||
toggleTheme: () => {
|
||||
@@ -59,11 +82,34 @@ export const useThemeStore = create<ThemeState>()(
|
||||
},
|
||||
|
||||
initializeTheme: () => {
|
||||
const { theme } = get();
|
||||
const { theme, activeThemeId, installedThemes } = get();
|
||||
const resolvedTheme = theme === 'system' ? getSystemTheme() : theme;
|
||||
applyTheme(resolvedTheme);
|
||||
set({ resolvedTheme, hydrated: true });
|
||||
|
||||
// Apply active custom theme on boot
|
||||
if (activeThemeId) {
|
||||
const t = installedThemes.find(t => t.id === activeThemeId);
|
||||
if (t) {
|
||||
// Load CSS from IndexedDB (may have been stripped from localStorage)
|
||||
if (t.css) {
|
||||
applyCustomThemeCSS(t, resolvedTheme);
|
||||
} else {
|
||||
pluginStorage.getThemeCSS(activeThemeId).then(css => {
|
||||
if (css) {
|
||||
injectThemeCSS(css);
|
||||
// Update the in-memory cache
|
||||
set({
|
||||
installedThemes: installedThemes.map(
|
||||
it => it.id === activeThemeId ? { ...it, css } : it
|
||||
),
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up previous listener if any
|
||||
if (mediaQueryCleanup) {
|
||||
mediaQueryCleanup();
|
||||
@@ -73,11 +119,15 @@ export const useThemeStore = create<ThemeState>()(
|
||||
if (typeof window !== 'undefined') {
|
||||
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
const handleChange = () => {
|
||||
const { theme } = get();
|
||||
const { theme, activeThemeId, installedThemes } = get();
|
||||
if (theme === 'system') {
|
||||
const newResolvedTheme = getSystemTheme();
|
||||
applyTheme(newResolvedTheme);
|
||||
set({ resolvedTheme: newResolvedTheme });
|
||||
if (activeThemeId) {
|
||||
const t = installedThemes.find(t => t.id === activeThemeId);
|
||||
if (t) applyCustomThemeCSS(t, newResolvedTheme);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -85,13 +135,122 @@ export const useThemeStore = create<ThemeState>()(
|
||||
mediaQueryCleanup = () => mediaQuery.removeEventListener('change', handleChange);
|
||||
}
|
||||
},
|
||||
|
||||
installTheme: async (file: File) => {
|
||||
const result = await extractTheme(file);
|
||||
if (!result.valid || !result.manifest) {
|
||||
return { success: false, error: result.errors.join('; '), warnings: result.warnings };
|
||||
}
|
||||
|
||||
const { manifest, css, preview } = result;
|
||||
const { installedThemes } = get();
|
||||
|
||||
// Check for duplicate
|
||||
if (installedThemes.some(t => t.id === manifest.id)) {
|
||||
// Update existing
|
||||
const sanitized = sanitizeThemeCSS(css);
|
||||
const theme: InstalledTheme = {
|
||||
id: manifest.id,
|
||||
name: manifest.name,
|
||||
version: manifest.version,
|
||||
author: manifest.author,
|
||||
description: manifest.description || '',
|
||||
preview: preview || undefined,
|
||||
css: sanitized.css,
|
||||
variants: manifest.variants,
|
||||
enabled: true,
|
||||
builtIn: false,
|
||||
};
|
||||
|
||||
await pluginStorage.saveThemeCSS(manifest.id, sanitized.css);
|
||||
if (preview) await pluginStorage.savePreview(manifest.id, preview);
|
||||
|
||||
set({
|
||||
installedThemes: installedThemes.map(t =>
|
||||
t.id === manifest.id ? theme : t
|
||||
),
|
||||
});
|
||||
|
||||
return { success: true, warnings: [...result.warnings, ...sanitized.warnings] };
|
||||
}
|
||||
|
||||
// Install new
|
||||
const sanitized = sanitizeThemeCSS(css);
|
||||
const theme: InstalledTheme = {
|
||||
id: manifest.id,
|
||||
name: manifest.name,
|
||||
version: manifest.version,
|
||||
author: manifest.author,
|
||||
description: manifest.description || '',
|
||||
preview: preview || undefined,
|
||||
css: sanitized.css,
|
||||
variants: manifest.variants,
|
||||
enabled: true,
|
||||
builtIn: false,
|
||||
};
|
||||
|
||||
await pluginStorage.saveThemeCSS(manifest.id, sanitized.css);
|
||||
if (preview) await pluginStorage.savePreview(manifest.id, preview);
|
||||
|
||||
set({ installedThemes: [...installedThemes, theme] });
|
||||
return { success: true, warnings: [...result.warnings, ...sanitized.warnings] };
|
||||
},
|
||||
|
||||
uninstallTheme: (id: string) => {
|
||||
const { installedThemes, activeThemeId } = get();
|
||||
const theme = installedThemes.find(t => t.id === id);
|
||||
if (!theme || theme.builtIn) return;
|
||||
|
||||
// Deactivate if active
|
||||
if (activeThemeId === id) {
|
||||
removeThemeCSS();
|
||||
set({ activeThemeId: null });
|
||||
}
|
||||
|
||||
// Clean up storage
|
||||
pluginStorage.deleteThemeCSS(id);
|
||||
pluginStorage.deletePreview(id);
|
||||
|
||||
set({
|
||||
installedThemes: installedThemes.filter(t => t.id !== id),
|
||||
});
|
||||
},
|
||||
|
||||
activateTheme: (id: string | null) => {
|
||||
if (id === null) {
|
||||
removeThemeCSS();
|
||||
set({ activeThemeId: null });
|
||||
return;
|
||||
}
|
||||
|
||||
const { installedThemes, resolvedTheme } = get();
|
||||
const theme = installedThemes.find(t => t.id === id);
|
||||
if (!theme) return;
|
||||
|
||||
applyCustomThemeCSS(theme, resolvedTheme);
|
||||
set({ activeThemeId: id });
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: 'theme-storage',
|
||||
partialize: (state) => ({ theme: state.theme }),
|
||||
partialize: (state) => ({
|
||||
theme: state.theme,
|
||||
activeThemeId: state.activeThemeId,
|
||||
// Store theme metadata but NOT full CSS (that goes in IndexedDB)
|
||||
installedThemes: state.installedThemes.map(t => ({
|
||||
...t,
|
||||
css: t.builtIn ? t.css : '', // only keep CSS for built-in themes
|
||||
preview: undefined, // previews also in IndexedDB
|
||||
})),
|
||||
}),
|
||||
onRehydrateStorage: () => {
|
||||
return (state) => {
|
||||
if (state) {
|
||||
// Ensure built-in themes are always present after rehydration
|
||||
const builtInIds = new Set(BUILTIN_THEMES.map(t => t.id));
|
||||
const userThemes = state.installedThemes.filter(t => !builtInIds.has(t.id));
|
||||
state.installedThemes = [...BUILTIN_THEMES, ...userThemes];
|
||||
|
||||
// Re-apply theme immediately after rehydration
|
||||
const resolvedTheme = state.theme === 'system' ? getSystemTheme() : state.theme;
|
||||
applyTheme(resolvedTheme);
|
||||
@@ -102,4 +261,14 @@ export const useThemeStore = create<ThemeState>()(
|
||||
},
|
||||
}
|
||||
)
|
||||
);
|
||||
);
|
||||
|
||||
/** Apply a custom theme's CSS, filtering to the appropriate variant */
|
||||
function applyCustomThemeCSS(theme: InstalledTheme, resolvedTheme: 'light' | 'dark'): void {
|
||||
// If theme only supports one variant and current mode doesn't match, skip
|
||||
if (!theme.variants.includes(resolvedTheme as ThemeVariant)) {
|
||||
removeThemeCSS();
|
||||
return;
|
||||
}
|
||||
injectThemeCSS(theme.css);
|
||||
}
|
||||
Reference in New Issue
Block a user