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,397 @@
|
||||
// Plugin & Theme system types
|
||||
|
||||
// ─── Common ──────────────────────────────────────────────────
|
||||
|
||||
export type Disposable = { dispose: () => void };
|
||||
export type MaybePromise<T> = T | Promise<T>;
|
||||
|
||||
export type PluginType = 'ui-extension' | 'sidebar-app' | 'hook' | 'theme';
|
||||
export type PluginStatus = 'installed' | 'enabled' | 'running' | 'disabled' | 'error';
|
||||
export type ThemeVariant = 'light' | 'dark';
|
||||
|
||||
// ─── Manifests ───────────────────────────────────────────────
|
||||
|
||||
export interface ThemeManifest {
|
||||
id: string;
|
||||
name: string;
|
||||
version: string;
|
||||
author: string;
|
||||
description: string;
|
||||
type: 'theme';
|
||||
preview?: string;
|
||||
variants: ThemeVariant[];
|
||||
minAppVersion?: string;
|
||||
}
|
||||
|
||||
export interface PluginManifest {
|
||||
id: string;
|
||||
name: string;
|
||||
version: string;
|
||||
author: string;
|
||||
description: string;
|
||||
type: Exclude<PluginType, 'theme'>;
|
||||
permissions: string[];
|
||||
entrypoint: string;
|
||||
minAppVersion?: string;
|
||||
settingsSchema?: Record<string, SettingFieldSchema>;
|
||||
}
|
||||
|
||||
export interface SettingFieldSchema {
|
||||
type: 'boolean' | 'string' | 'number' | 'select';
|
||||
label: string;
|
||||
description?: string;
|
||||
default: unknown;
|
||||
options?: string[];
|
||||
min?: number;
|
||||
max?: number;
|
||||
}
|
||||
|
||||
// ─── Installed Items ─────────────────────────────────────────
|
||||
|
||||
export interface InstalledTheme {
|
||||
id: string;
|
||||
name: string;
|
||||
version: string;
|
||||
author: string;
|
||||
description: string;
|
||||
preview?: string; // data: URI or blob URL
|
||||
css: string; // raw CSS text
|
||||
variants: ThemeVariant[];
|
||||
enabled: boolean;
|
||||
builtIn: boolean;
|
||||
}
|
||||
|
||||
export interface InstalledPlugin {
|
||||
id: string;
|
||||
name: string;
|
||||
version: string;
|
||||
author: string;
|
||||
description: string;
|
||||
type: Exclude<PluginType, 'theme'>;
|
||||
permissions: string[];
|
||||
entrypoint: string;
|
||||
enabled: boolean;
|
||||
status: PluginStatus;
|
||||
error?: string;
|
||||
settingsSchema?: Record<string, SettingFieldSchema>;
|
||||
settings: Record<string, unknown>;
|
||||
}
|
||||
|
||||
// ─── UI Slots ────────────────────────────────────────────────
|
||||
|
||||
export type SlotName =
|
||||
| 'toolbar-actions'
|
||||
| 'email-banner'
|
||||
| 'email-footer'
|
||||
| 'composer-toolbar'
|
||||
| 'sidebar-widget'
|
||||
| 'settings-section'
|
||||
| 'context-menu-email'
|
||||
| 'navigation-rail-bottom';
|
||||
|
||||
export interface SlotRegistration {
|
||||
pluginId: string;
|
||||
component: React.ComponentType<Record<string, unknown>>;
|
||||
order: number;
|
||||
}
|
||||
|
||||
// ─── Plugin API Types ────────────────────────────────────────
|
||||
|
||||
export interface ToolbarAction {
|
||||
id: string;
|
||||
label: string;
|
||||
icon?: string;
|
||||
onClick: () => void;
|
||||
order?: number;
|
||||
}
|
||||
|
||||
export interface BannerFactory {
|
||||
shouldShow: (email: EmailReadView) => boolean;
|
||||
render: React.ComponentType<{ email: EmailReadView }>;
|
||||
}
|
||||
|
||||
export interface SettingsSection {
|
||||
id: string;
|
||||
label: string;
|
||||
icon?: string;
|
||||
render: React.ComponentType;
|
||||
}
|
||||
|
||||
export interface ComposerAction {
|
||||
id: string;
|
||||
label: string;
|
||||
icon?: string;
|
||||
onClick: () => void;
|
||||
order?: number;
|
||||
}
|
||||
|
||||
export interface SidebarWidget {
|
||||
id: string;
|
||||
label: string;
|
||||
render: React.ComponentType;
|
||||
order?: number;
|
||||
}
|
||||
|
||||
export interface ContextMenuItem {
|
||||
id: string;
|
||||
label: string;
|
||||
icon?: string;
|
||||
onClick: (emailIds: string[]) => void;
|
||||
order?: number;
|
||||
}
|
||||
|
||||
export interface KeyboardShortcut {
|
||||
id: string;
|
||||
keys: string;
|
||||
label: string;
|
||||
category: string;
|
||||
handler: () => void;
|
||||
}
|
||||
|
||||
// ─── Read-Only View Types ────────────────────────────────────
|
||||
// Projected views exposed to plugins — no direct store references
|
||||
|
||||
export interface EmailReadView {
|
||||
id: string;
|
||||
threadId: string;
|
||||
mailboxIds: string[];
|
||||
from: { name: string; email: string }[];
|
||||
to: { name: string; email: string }[];
|
||||
cc: { name: string; email: string }[];
|
||||
subject: string;
|
||||
receivedAt: string;
|
||||
isRead: boolean;
|
||||
isFlagged: boolean;
|
||||
hasAttachment: boolean;
|
||||
preview: string;
|
||||
keywords: string[];
|
||||
}
|
||||
|
||||
export interface DraftView {
|
||||
to: string[];
|
||||
cc: string[];
|
||||
bcc: string[];
|
||||
subject: string;
|
||||
htmlBody: string;
|
||||
textBody: string;
|
||||
identityId: string;
|
||||
inReplyTo?: string;
|
||||
attachments: { name: string; type: string; size: number }[];
|
||||
}
|
||||
|
||||
export interface MailboxView {
|
||||
id: string;
|
||||
name: string;
|
||||
role: string | null;
|
||||
totalEmails: number;
|
||||
unreadEmails: number;
|
||||
parentId: string | null;
|
||||
}
|
||||
|
||||
export interface CalendarEventView {
|
||||
id: string;
|
||||
calendarId: string;
|
||||
title: string;
|
||||
description: string;
|
||||
start: string;
|
||||
end: string;
|
||||
isAllDay: boolean;
|
||||
location: string;
|
||||
status: string;
|
||||
recurrenceRule?: string;
|
||||
}
|
||||
|
||||
export interface CalendarView {
|
||||
id: string;
|
||||
name: string;
|
||||
color: string;
|
||||
isVisible: boolean;
|
||||
isDefault: boolean;
|
||||
}
|
||||
|
||||
export interface ContactView {
|
||||
id: string;
|
||||
addressBookId: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
emails: string[];
|
||||
phones: string[];
|
||||
company: string;
|
||||
notes: string;
|
||||
}
|
||||
|
||||
export interface AddressBookView {
|
||||
id: string;
|
||||
name: string;
|
||||
isDefault: boolean;
|
||||
}
|
||||
|
||||
export interface ContactGroupView {
|
||||
id: string;
|
||||
name: string;
|
||||
memberCount: number;
|
||||
}
|
||||
|
||||
export interface FileResourceView {
|
||||
id: string;
|
||||
name: string;
|
||||
type: 'file' | 'directory';
|
||||
size: number;
|
||||
mimeType: string;
|
||||
path: string;
|
||||
modified: string;
|
||||
}
|
||||
|
||||
export interface IdentityView {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
replyTo: string | null;
|
||||
bcc: string | null;
|
||||
htmlSignature: string;
|
||||
textSignature: string;
|
||||
}
|
||||
|
||||
export interface TaskView {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
isComplete: boolean;
|
||||
dueDate: string | null;
|
||||
priority: string;
|
||||
calendarId: string;
|
||||
}
|
||||
|
||||
export interface TemplateView {
|
||||
id: string;
|
||||
name: string;
|
||||
subject: string;
|
||||
htmlBody: string;
|
||||
textBody: string;
|
||||
}
|
||||
|
||||
export interface FilterRuleView {
|
||||
id: string;
|
||||
name: string;
|
||||
isActive: boolean;
|
||||
conditions: unknown[];
|
||||
actions: unknown[];
|
||||
}
|
||||
|
||||
export interface KeywordView {
|
||||
id: string;
|
||||
name: string;
|
||||
color: string;
|
||||
}
|
||||
|
||||
export interface QuotaView {
|
||||
used: number;
|
||||
total: number;
|
||||
percentUsed: number;
|
||||
}
|
||||
|
||||
export interface CalendarAlertView {
|
||||
id: string;
|
||||
eventId: string;
|
||||
eventTitle: string;
|
||||
triggerTime: string;
|
||||
}
|
||||
|
||||
export interface SearchFilters {
|
||||
from?: string;
|
||||
to?: string;
|
||||
subject?: string;
|
||||
hasAttachment?: boolean;
|
||||
after?: string;
|
||||
before?: string;
|
||||
inMailbox?: string;
|
||||
}
|
||||
|
||||
export interface NewEmailNotification {
|
||||
emailId: string;
|
||||
from: { name: string; email: string };
|
||||
subject: string;
|
||||
preview: string;
|
||||
}
|
||||
|
||||
export interface VacationView {
|
||||
isEnabled: boolean;
|
||||
subject: string;
|
||||
htmlBody: string;
|
||||
textBody: string;
|
||||
fromDate: string | null;
|
||||
toDate: string | null;
|
||||
}
|
||||
|
||||
export interface KeyboardEventView {
|
||||
key: string;
|
||||
code: string;
|
||||
ctrlKey: boolean;
|
||||
shiftKey: boolean;
|
||||
altKey: boolean;
|
||||
metaKey: boolean;
|
||||
}
|
||||
|
||||
export interface AppConfigView {
|
||||
appName: string;
|
||||
demoMode: boolean;
|
||||
stalwartFeaturesEnabled: boolean;
|
||||
oauthEnabled: boolean;
|
||||
}
|
||||
|
||||
export interface FileInfo {
|
||||
name: string;
|
||||
size: number;
|
||||
type: string;
|
||||
}
|
||||
|
||||
export interface ComposerContext {
|
||||
mode: 'new' | 'reply' | 'reply-all' | 'forward';
|
||||
inReplyToId?: string;
|
||||
originalSubject?: string;
|
||||
}
|
||||
|
||||
// ─── Permission Reference ────────────────────────────────────
|
||||
|
||||
export const ALL_PERMISSIONS = [
|
||||
'email:read', 'email:write', 'email:send',
|
||||
'calendar:read', 'calendar:write',
|
||||
'contacts:read', 'contacts:write',
|
||||
'files:read', 'files:write',
|
||||
'identity:read', 'identity:write',
|
||||
'filters:read', 'filters:write',
|
||||
'tasks:read', 'tasks:write',
|
||||
'templates:read', 'templates:write',
|
||||
'smime:read',
|
||||
'vacation:read', 'vacation:write',
|
||||
'settings:read', 'settings:write',
|
||||
'security:read',
|
||||
'auth:observe',
|
||||
'ui:observe', 'ui:toolbar', 'ui:email-banner', 'ui:email-footer',
|
||||
'ui:composer-toolbar', 'ui:sidebar-widget', 'ui:settings-section',
|
||||
'ui:context-menu', 'ui:navigation-rail', 'ui:keyboard',
|
||||
'app:lifecycle',
|
||||
] as const;
|
||||
|
||||
export type Permission = (typeof ALL_PERMISSIONS)[number];
|
||||
|
||||
/** Permissions always granted regardless of manifest */
|
||||
export const IMPLICIT_PERMISSIONS: Permission[] = ['ui:observe', 'app:lifecycle'];
|
||||
|
||||
// ─── Validation ──────────────────────────────────────────────
|
||||
|
||||
export const MAX_PLUGIN_SIZE = 5 * 1024 * 1024; // 5 MB
|
||||
export const MAX_THEME_SIZE = 1 * 1024 * 1024; // 1 MB
|
||||
|
||||
export const ALLOWED_PLUGIN_FILES = new Set([
|
||||
'.js', '.mjs', '.css', '.json', '.png', '.svg', '.woff2', '.jpg', '.jpeg', '.webp',
|
||||
]);
|
||||
|
||||
export const DISALLOWED_CSS_PATTERNS = [
|
||||
/@import\b/i,
|
||||
/url\s*\(\s*['"]?https?:/i,
|
||||
/expression\s*\(/i,
|
||||
/javascript\s*:/i,
|
||||
/-moz-binding/i,
|
||||
/behavior\s*:/i,
|
||||
];
|
||||
Reference in New Issue
Block a user