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:
Linus Rath
2026-03-25 00:44:03 +01:00
parent 78bcf8db1b
commit 76b21147e4
63 changed files with 7894 additions and 67 deletions
+244
View File
@@ -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;
}
};
},
}
)
);
+55
View File
@@ -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
View File
@@ -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);
}