diff --git a/app/[locale]/settings/page.tsx b/app/[locale]/settings/page.tsx index d9d81a80..c239ea8e 100644 --- a/app/[locale]/settings/page.tsx +++ b/app/[locale]/settings/page.tsx @@ -25,6 +25,7 @@ import { KeyRound, PanelLeftClose, Bell, + Puzzle, type LucideIcon, } from 'lucide-react'; import { Button } from '@/components/ui/button'; @@ -46,6 +47,8 @@ import { ContactsSettings } from '@/components/settings/contacts-settings'; import { SmimeSettings } from '@/components/settings/smime-settings'; import { SidebarAppsSettings } from '@/components/settings/sidebar-apps-settings'; import { NotificationSettings } from '@/components/settings/notification-settings'; +import { ThemesSettings } from '@/components/settings/themes-settings'; +import { PluginsSettings } from '@/components/settings/plugins-settings'; import { useAuthStore, redirectToLogin } from '@/stores/auth-store'; import { useEmailStore } from '@/stores/email-store'; import { useIsDesktop } from '@/hooks/use-media-query'; @@ -55,9 +58,10 @@ import { InlineAppView } from '@/components/layout/inline-app-view'; import { useSidebarApps } from '@/hooks/use-sidebar-apps'; import { ResizeHandle } from '@/components/layout/resize-handle'; import { useConfig } from '@/hooks/use-config'; +import { usePolicyStore } from '@/stores/policy-store'; import { cn } from '@/lib/utils'; -type Tab = 'appearance' | 'email' | 'notifications' | 'account' | 'security' | 'identities' | 'encryption' | 'vacation' | 'calendar' | 'contacts' | 'filters' | 'templates' | 'folders' | 'keywords' | 'files' | 'sidebar_apps' | 'advanced'; +type Tab = 'appearance' | 'email' | 'notifications' | 'account' | 'security' | 'identities' | 'encryption' | 'vacation' | 'calendar' | 'contacts' | 'filters' | 'templates' | 'folders' | 'keywords' | 'files' | 'sidebar_apps' | 'themes' | 'plugins' | 'advanced'; type TabGroup = 'general' | 'account' | 'organization' | 'apps' | 'system'; interface TabDef { @@ -84,6 +88,8 @@ const tabIcons: Record = { keywords: Tags, files: HardDrive, sidebar_apps: PanelLeftClose, + themes: Palette, + plugins: Puzzle, advanced: Wrench, }; @@ -98,6 +104,7 @@ export default function SettingsPage() { const [initialCheckDone, setInitialCheckDone] = useState(() => useAuthStore.getState().isAuthenticated && !!useAuthStore.getState().client); const { quota, isPushConnected } = useEmailStore(); const { stalwartFeaturesEnabled } = useConfig(); + const { isFeatureEnabled } = usePolicyStore(); const [activeTab, setActiveTab] = useState(() => { try { const saved = localStorage.getItem('settings-active-tab'); @@ -145,16 +152,18 @@ export default function SettingsPage() { { id: 'account', label: t('tabs.account'), icon: tabIcons.account, group: 'account' }, ...(stalwartFeaturesEnabled ? [{ id: 'security' as Tab, label: t('tabs.security'), icon: tabIcons.security, group: 'account' as TabGroup }] : []), { id: 'identities', label: t('tabs.identities'), icon: tabIcons.identities, group: 'account' }, - { id: 'encryption', label: t('tabs.encryption'), icon: tabIcons.encryption, group: 'account' }, + ...(isFeatureEnabled('smimeEnabled') ? [{ id: 'encryption' as Tab, label: t('tabs.encryption'), icon: tabIcons.encryption, group: 'account' as TabGroup }] : []), ...(supportsVacation ? [{ id: 'vacation' as Tab, label: t('tabs.vacation'), icon: tabIcons.vacation, group: 'account' as TabGroup }] : []), ...(supportsSieve ? [{ id: 'filters' as Tab, label: t('tabs.filters'), icon: tabIcons.filters, group: 'organization' as TabGroup }] : []), - { id: 'templates', label: t('tabs.templates'), icon: tabIcons.templates, group: 'organization' }, + ...(isFeatureEnabled('templatesEnabled') ? [{ id: 'templates' as Tab, label: t('tabs.templates'), icon: tabIcons.templates, group: 'organization' as TabGroup }] : []), { id: 'folders', label: t('tabs.folders'), icon: tabIcons.folders, group: 'organization' }, - { id: 'keywords', label: t('tabs.keywords'), icon: tabIcons.keywords, group: 'organization' }, + ...(isFeatureEnabled('customKeywordsEnabled') ? [{ id: 'keywords' as Tab, label: t('tabs.keywords'), icon: tabIcons.keywords, group: 'organization' as TabGroup }] : []), ...(supportsCalendar ? [{ id: 'calendar' as Tab, label: t('tabs.calendar'), icon: tabIcons.calendar, group: 'apps' as TabGroup }] : []), { id: 'contacts', label: t('tabs.contacts'), icon: tabIcons.contacts, group: 'apps' }, ...(supportsFiles ? [{ id: 'files' as Tab, label: t('tabs.files'), icon: tabIcons.files, group: 'apps' as TabGroup }] : []), - { id: 'sidebar_apps', label: t('tabs.sidebar_apps'), icon: tabIcons.sidebar_apps, group: 'apps' }, + ...(isFeatureEnabled('sidebarAppsEnabled') ? [{ id: 'sidebar_apps' as Tab, label: t('tabs.sidebar_apps'), icon: tabIcons.sidebar_apps, group: 'apps' as TabGroup }] : []), + { id: 'themes' as Tab, label: 'Themes', icon: tabIcons.themes, group: 'system' as TabGroup }, + { id: 'plugins' as Tab, label: 'Plugins', icon: tabIcons.plugins, group: 'system' as TabGroup }, { id: 'advanced', label: t('tabs.advanced'), icon: tabIcons.advanced, group: 'system' }, ]; @@ -195,6 +204,8 @@ export default function SettingsPage() { {activeTab === 'keywords' && } {activeTab === 'files' && } {activeTab === 'sidebar_apps' && } + {activeTab === 'themes' && } + {activeTab === 'plugins' && } {activeTab === 'advanced' && } ); diff --git a/app/admin/auth/page.tsx b/app/admin/auth/page.tsx new file mode 100644 index 00000000..e3aaca23 --- /dev/null +++ b/app/admin/auth/page.tsx @@ -0,0 +1,217 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { Save, Loader2, RotateCcw } from 'lucide-react'; + +interface ConfigEntry { + value: unknown; + source: 'admin' | 'env' | 'default'; +} + +export default function AdminAuthPage() { + const [config, setConfig] = useState>({}); + const [edits, setEdits] = useState>({}); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null); + + useEffect(() => { fetchConfig(); }, []); + + async function fetchConfig() { + setLoading(true); + const res = await fetch('/api/admin/config'); + if (res.ok) setConfig(await res.json()); + setLoading(false); + } + + function handleChange(key: string, value: unknown) { + setEdits(prev => ({ ...prev, [key]: value })); + setMessage(null); + } + + function currentValue(key: string): unknown { + if (key in edits) return edits[key]; + return config[key]?.value; + } + + async function handleSave() { + if (Object.keys(edits).length === 0) return; + setSaving(true); + setMessage(null); + + const res = await fetch('/api/admin/config', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(edits), + }); + + if (res.ok) { + setMessage({ type: 'success', text: 'Authentication settings saved.' }); + setEdits({}); + await fetchConfig(); + } else { + const data = await res.json(); + setMessage({ type: 'error', text: data.error || 'Failed to save' }); + } + setSaving(false); + } + + async function handleRevert(key: string) { + const res = await fetch('/api/admin/config', { + method: 'DELETE', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ key }), + }); + if (res.ok) { + setEdits(prev => { const next = { ...prev }; delete next[key]; return next; }); + await fetchConfig(); + } + } + + const hasEdits = Object.keys(edits).length > 0; + + if (loading) { + return
Loading...
; + } + + return ( +
+
+
+

Authentication

+

OAuth, SSO, and session configuration

+
+ {hasEdits && ( + + )} +
+ + {message && ( +
+ {message.text} +
+ )} + + {/* OAuth */} +
+ + + + + +
+ + {/* SSO */} +
+ +
+ + {/* Session & Security */} +
+ onChange(configKey, e.target.value)} placeholder={placeholder} + className="h-8 w-64 rounded-md border border-input bg-background px-2.5 text-sm text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" /> + {source === 'admin' && ( + + )} +
+ + ); +} + +function Toggle({ label, description, configKey, value, source, onChange, onRevert }: { + label: string; description?: string; configKey: string; value: boolean; source?: string; + onChange: (k: string, v: unknown) => void; onRevert: (k: string) => void; +}) { + return ( +
+
+
+ {label} + +
+ {description &&

{description}

} +
+
+ + {source === 'admin' && ( + + )} +
+
+ ); +} + +function Select({ label, configKey, value, source, options, onChange, onRevert }: { + label: string; configKey: string; value: string; source?: string; options: string[]; + onChange: (k: string, v: unknown) => void; onRevert: (k: string) => void; +}) { + return ( +
+
+ {label} + +
+
+ + {source === 'admin' && ( + + )} +
+
+ ); +} diff --git a/app/admin/branding/page.tsx b/app/admin/branding/page.tsx new file mode 100644 index 00000000..33bb12aa --- /dev/null +++ b/app/admin/branding/page.tsx @@ -0,0 +1,299 @@ +'use client'; + +import { useEffect, useRef, useState } from 'react'; +import { Save, Loader2, RotateCcw, ImageIcon, Upload, Trash2 } from 'lucide-react'; + +interface ConfigEntry { + value: unknown; + source: 'admin' | 'env' | 'default'; +} + +const IMAGE_FIELDS = [ + { key: 'faviconUrl', label: 'Favicon', accept: '.svg,.png,.ico,.webp' }, + { key: 'appLogoLightUrl', label: 'App Logo (Light Mode)', accept: '.svg,.png,.jpg,.webp' }, + { key: 'appLogoDarkUrl', label: 'App Logo (Dark Mode)', accept: '.svg,.png,.jpg,.webp' }, + { key: 'loginLogoLightUrl', label: 'Login Logo (Light Mode)', accept: '.svg,.png,.jpg,.webp' }, + { key: 'loginLogoDarkUrl', label: 'Login Logo (Dark Mode)', accept: '.svg,.png,.jpg,.webp' }, +]; + +const TEXT_FIELDS = [ + { key: 'loginCompanyName', label: 'Company Name' }, + { key: 'loginImprintUrl', label: 'Imprint URL' }, + { key: 'loginPrivacyPolicyUrl', label: 'Privacy Policy URL' }, + { key: 'loginWebsiteUrl', label: 'Company Website URL' }, +]; + +export default function AdminBrandingPage() { + const [config, setConfig] = useState>({}); + const [edits, setEdits] = useState>({}); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [uploading, setUploading] = useState(null); + const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null); + const fileInputRefs = useRef>({}); + + useEffect(() => { + fetchConfig(); + }, []); + + async function fetchConfig() { + setLoading(true); + const res = await fetch('/api/admin/config'); + if (res.ok) setConfig(await res.json()); + setLoading(false); + } + + function handleChange(key: string, value: string) { + setEdits(prev => ({ ...prev, [key]: value })); + setMessage(null); + } + + function currentValue(key: string): string { + if (key in edits) return edits[key] as string; + return (config[key]?.value as string) ?? ''; + } + + async function handleSave() { + if (Object.keys(edits).length === 0) return; + setSaving(true); + setMessage(null); + + const res = await fetch('/api/admin/config', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(edits), + }); + + if (res.ok) { + setMessage({ type: 'success', text: 'Branding updated. Changes visible on next page load.' }); + setEdits({}); + await fetchConfig(); + } else { + const data = await res.json(); + setMessage({ type: 'error', text: data.error || 'Failed to save' }); + } + setSaving(false); + } + + async function handleUpload(slot: string, file: File) { + setUploading(slot); + setMessage(null); + + const formData = new FormData(); + formData.append('file', file); + formData.append('slot', slot); + + const res = await fetch('/api/admin/branding', { + method: 'POST', + body: formData, + }); + + if (res.ok) { + const data = await res.json(); + setMessage({ type: 'success', text: `Uploaded ${file.name} successfully.` }); + // Remove any pending URL edit for this slot since upload sets it + setEdits(prev => { + const next = { ...prev }; + delete next[slot]; + return next; + }); + // Update config to reflect the uploaded URL + setConfig(prev => ({ + ...prev, + [slot]: { value: data.url, source: 'admin' }, + })); + } else { + const data = await res.json(); + setMessage({ type: 'error', text: data.error || 'Upload failed' }); + } + setUploading(null); + } + + async function handleDeleteUpload(slot: string) { + setMessage(null); + + const res = await fetch('/api/admin/branding', { + method: 'DELETE', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ slot }), + }); + + if (res.ok) { + setMessage({ type: 'success', text: 'Uploaded file removed. Reverted to default.' }); + setEdits(prev => { + const next = { ...prev }; + delete next[slot]; + return next; + }); + await fetchConfig(); + } else { + const data = await res.json(); + setMessage({ type: 'error', text: data.error || 'Failed to remove' }); + } + } + + async function handleRevert(key: string) { + const res = await fetch('/api/admin/config', { + method: 'DELETE', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ key }), + }); + if (res.ok) { + setEdits(prev => { + const next = { ...prev }; + delete next[key]; + return next; + }); + await fetchConfig(); + } + } + + const isUploadedFile = (key: string): boolean => { + const val = currentValue(key); + return val.startsWith('/api/admin/branding/'); + }; + + const hasEdits = Object.keys(edits).length > 0; + + if (loading) { + return
Loading...
; + } + + return ( +
+
+
+

Branding

+

Customize logos, favicon, and company information

+
+ {hasEdits && ( + + )} +
+ + {message && ( +
+ {message.text} +
+ )} + +
+
+

Images & Logos

+

Upload a file or enter a URL. Supported formats: SVG, PNG, JPEG, WebP, ICO (max 2 MB)

+
+
+ {IMAGE_FIELDS.map(field => ( +
+
+
+ + {config[field.key]?.source === 'admin' && ( + + {isUploadedFile(field.key) ? 'uploaded' : 'admin'} + + )} +
+
+ handleChange(field.key, e.target.value)} + placeholder="Enter URL or upload a file" + className="h-8 w-64 rounded-md border border-input bg-background px-2.5 text-sm text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" + /> + { fileInputRefs.current[field.key] = el; }} + type="file" + accept={field.accept} + className="hidden" + onChange={(e) => { + const file = e.target.files?.[0]; + if (file) handleUpload(field.key, file); + e.target.value = ''; + }} + /> + + {isUploadedFile(field.key) && ( + + )} + {config[field.key]?.source === 'admin' && !isUploadedFile(field.key) && ( + + )} +
+
+ {/* Preview */} + {currentValue(field.key) && ( +
+ +
+ {field.label} { (e.target as HTMLImageElement).style.display = 'none'; }} + /> +
+
+ )} +
+ ))} +
+
+ +
+
+

Company Information

+
+
+ {TEXT_FIELDS.map(field => ( +
+
+ + {config[field.key]?.source === 'admin' && ( + admin + )} +
+
+ handleChange(field.key, e.target.value)} + placeholder={field.key.includes('Url') ? 'https://...' : 'Enter value'} + className="h-8 w-72 rounded-md border border-input bg-background px-2.5 text-sm text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" + /> + {config[field.key]?.source === 'admin' && ( + + )} +
+
+ ))} +
+
+
+ ); +} diff --git a/app/admin/change-password/page.tsx b/app/admin/change-password/page.tsx new file mode 100644 index 00000000..6e220d3a --- /dev/null +++ b/app/admin/change-password/page.tsx @@ -0,0 +1,116 @@ +'use client'; + +import { useState } from 'react'; +import { useRouter } from 'next/navigation'; +import { Lock } from 'lucide-react'; + +export default function ChangePasswordPage() { + const router = useRouter(); + const [currentPassword, setCurrentPassword] = useState(''); + const [newPassword, setNewPassword] = useState(''); + const [confirmPassword, setConfirmPassword] = useState(''); + const [error, setError] = useState(''); + const [success, setSuccess] = useState(false); + const [loading, setLoading] = useState(false); + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + setError(''); + setSuccess(false); + + if (newPassword.length < 8) { + setError('New password must be at least 8 characters.'); + return; + } + if (newPassword !== confirmPassword) { + setError('New passwords do not match.'); + return; + } + + setLoading(true); + const res = await fetch('/api/admin/change-password', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ currentPassword, newPassword }), + }); + + if (res.ok) { + setSuccess(true); + setCurrentPassword(''); + setNewPassword(''); + setConfirmPassword(''); + setTimeout(() => router.push('/admin'), 2000); + } else { + const data = await res.json().catch(() => ({})); + setError(data.error || 'Failed to change password.'); + } + setLoading(false); + } + + return ( +
+
+

Change Password

+

Update your admin password.

+
+ +
+
+ +
+ + setCurrentPassword(e.target.value)} + required + className="w-full h-9 pl-9 pr-3 rounded-md border border-input bg-background text-sm text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" + autoComplete="current-password" + /> +
+
+ +
+ + setNewPassword(e.target.value)} + required + minLength={8} + className="w-full h-9 px-3 rounded-md border border-input bg-background text-sm text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" + autoComplete="new-password" + /> +
+ +
+ + setConfirmPassword(e.target.value)} + required + minLength={8} + className="w-full h-9 px-3 rounded-md border border-input bg-background text-sm text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" + autoComplete="new-password" + /> +
+ + {error && ( +

{error}

+ )} + {success && ( +

Password changed. Redirecting...

+ )} + + +
+
+ ); +} diff --git a/app/admin/layout.tsx b/app/admin/layout.tsx new file mode 100644 index 00000000..4c367f4e --- /dev/null +++ b/app/admin/layout.tsx @@ -0,0 +1,140 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { useRouter, usePathname } from 'next/navigation'; +import Link from 'next/link'; +import { + LayoutDashboard, + Settings, + Palette, + Shield, + Scale, + ScrollText, + LogOut, + KeyRound, +} from 'lucide-react'; +import { cn } from '@/lib/utils'; +import { useConfig } from '@/hooks/use-config'; +import { useThemeStore } from '@/stores/theme-store'; + +const NAV_ITEMS = [ + { href: '/admin', label: 'Dashboard', icon: LayoutDashboard }, + { href: '/admin/settings', label: 'Settings', icon: Settings }, + { href: '/admin/branding', label: 'Branding', icon: Palette }, + { href: '/admin/auth', label: 'Authentication', icon: Shield }, + { href: '/admin/policy', label: 'Policy', icon: Scale }, + { href: '/admin/logs', label: 'Audit Log', icon: ScrollText }, +]; + +export default function AdminLayout({ children }: { children: React.ReactNode }) { + const router = useRouter(); + const pathname = usePathname(); + const [authenticated, setAuthenticated] = useState(null); + const { appLogoLightUrl, appLogoDarkUrl, loginLogoLightUrl, loginLogoDarkUrl } = useConfig(); + const resolvedTheme = useThemeStore((s) => s.resolvedTheme); + const logoUrl = resolvedTheme === 'dark' + ? (appLogoDarkUrl || appLogoLightUrl || loginLogoDarkUrl) + : (appLogoLightUrl || appLogoDarkUrl || loginLogoLightUrl); + + useEffect(() => { + checkAuth(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + async function checkAuth() { + try { + const res = await fetch('/api/admin/auth'); + const data = await res.json(); + if (!data.enabled) { + router.replace('/'); + return; + } + if (!data.authenticated) { + router.replace('/admin/login'); + return; + } + setAuthenticated(true); + } catch { + router.replace('/admin/login'); + } + } + + async function handleLogout() { + await fetch('/api/admin/auth', { method: 'DELETE' }); + router.replace('/admin/login'); + } + + // Don't gate the login page + if (pathname === '/admin/login') { + return <>{children}; + } + + if (authenticated === null) { + return ( +
+
Loading...
+
+ ); + } + + return ( +
+ {/* Sidebar */} + + + {/* Main content */} +
+
+ {children} +
+
+
+ ); +} diff --git a/app/admin/login/page.tsx b/app/admin/login/page.tsx new file mode 100644 index 00000000..ec426b3f --- /dev/null +++ b/app/admin/login/page.tsx @@ -0,0 +1,95 @@ +'use client'; + +import { useState, type FormEvent } from 'react'; +import { useRouter } from 'next/navigation'; +import { Shield } from 'lucide-react'; +import { useConfig } from '@/hooks/use-config'; +import { useThemeStore } from '@/stores/theme-store'; + +export default function AdminLoginPage() { + const router = useRouter(); + const [password, setPassword] = useState(''); + const [error, setError] = useState(''); + const [loading, setLoading] = useState(false); + const { loginLogoLightUrl, loginLogoDarkUrl } = useConfig(); + const resolvedTheme = useThemeStore((s) => s.resolvedTheme); + const logoUrl = resolvedTheme === 'dark' ? loginLogoDarkUrl : loginLogoLightUrl; + + async function handleSubmit(e: FormEvent) { + e.preventDefault(); + setError(''); + setLoading(true); + + try { + const res = await fetch('/api/admin/auth', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ password }), + }); + + const data = await res.json(); + + if (!res.ok) { + setError(data.error || 'Login failed'); + return; + } + + router.push('/admin'); + } catch { + setError('Network error. Please try again.'); + } finally { + setLoading(false); + } + } + + return ( +
+
+
+
+ {logoUrl ? ( + + ) : ( + + )} +
+

Admin Dashboard

+

Enter your admin password to continue

+
+ +
+
+ + setPassword(e.target.value)} + className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground transition-all duration-200 placeholder:text-muted-foreground hover:border-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:border-ring" + placeholder="Enter admin password" + required + autoFocus + autoComplete="current-password" + /> +
+ + {error && ( +
+ {error} +
+ )} + + +
+
+
+ ); +} diff --git a/app/admin/logs/page.tsx b/app/admin/logs/page.tsx new file mode 100644 index 00000000..9c79a3da --- /dev/null +++ b/app/admin/logs/page.tsx @@ -0,0 +1,149 @@ +'use client'; + +import { useEffect, useState, useCallback } from 'react'; +import { RefreshCw } from 'lucide-react'; +import type { AuditEntry } from '@/lib/admin/types'; + +export default function AdminLogsPage() { + const [entries, setEntries] = useState([]); + const [total, setTotal] = useState(0); + const [page, setPage] = useState(1); + const [loading, setLoading] = useState(true); + const [actionFilter, setActionFilter] = useState(''); + const limit = 50; + + const fetchLogs = useCallback(async () => { + setLoading(true); + const params = new URLSearchParams({ page: String(page), limit: String(limit) }); + if (actionFilter) params.set('action', actionFilter); + + const res = await fetch(`/api/admin/audit?${params}`); + if (res.ok) { + const data = await res.json(); + setEntries(data.entries || []); + setTotal(data.total || 0); + } + setLoading(false); + }, [page, actionFilter]); + + useEffect(() => { fetchLogs(); }, [fetchLogs]); + + const totalPages = Math.max(1, Math.ceil(total / limit)); + + return ( +
+
+
+

Audit Log

+

{total} total entries

+
+ +
+ + {/* Filter */} +
+ +
+ + {/* Table */} +
+ + + + + + + + + + + {loading && entries.length === 0 ? ( + + + + ) : entries.length === 0 ? ( + + + + ) : ( + entries.map((entry, i) => ( + + + + + + + )) + )} + +
TimeActionDetailsIP
Loading...
No entries found
+ {new Date(entry.ts).toLocaleString()} + + + {entry.action} + + + {formatDetail(entry.detail)} + + {entry.ip} +
+
+ + {/* Pagination */} + {totalPages > 1 && ( +
+

+ Page {page} of {totalPages} +

+
+ + +
+
+ )} +
+ ); +} + +function formatDetail(detail: Record): string { + if (!detail || Object.keys(detail).length === 0) return '—'; + if (detail.reason) return String(detail.reason); + if (detail.key) return `${detail.key}: ${JSON.stringify(detail.old)} → ${JSON.stringify(detail.new)}`; + if (detail.changes && Array.isArray(detail.changes)) { + return detail.changes.map((c: Record) => `${c.key}`).join(', '); + } + if (detail.restrictionCount !== undefined) return `${detail.restrictionCount} restriction(s)`; + return JSON.stringify(detail).slice(0, 100); +} diff --git a/app/admin/page.tsx b/app/admin/page.tsx new file mode 100644 index 00000000..98f031c8 --- /dev/null +++ b/app/admin/page.tsx @@ -0,0 +1,182 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { Server, AlertTriangle, Clock, Globe } from 'lucide-react'; +import type { AuditEntry } from '@/lib/admin/types'; + +interface AdminStatus { + enabled: boolean; + authenticated: boolean; + lastLogin: string | null; + passwordChangedAt: string | null; +} + +interface ConfigData { + appName?: string; + jmapServerUrl?: string; + settingsSyncEnabled?: boolean; + stalwartFeaturesEnabled?: boolean; + oauthEnabled?: boolean; + devMode?: boolean; +} + +export default function AdminDashboardPage() { + const [status, setStatus] = useState(null); + const [recentActivity, setRecentActivity] = useState([]); + const [config, setConfig] = useState(null); + const [, setConfigSources] = useState | null>(null); + const [warnings, setWarnings] = useState([]); + + useEffect(() => { + fetchDashboardData(); + }, []); + + async function fetchDashboardData() { + const [statusRes, auditRes, configRes, adminConfigRes] = await Promise.all([ + fetch('/api/admin/auth'), + fetch('/api/admin/audit?limit=10'), + fetch('/api/config'), + fetch('/api/admin/config'), + ]); + + if (statusRes.ok) setStatus(await statusRes.json()); + if (auditRes.ok) { + const data = await auditRes.json(); + setRecentActivity(data.entries || []); + } + if (configRes.ok) setConfig(await configRes.json()); + + // Build warnings + const w: string[] = []; + if (adminConfigRes.ok) { + const sources = await adminConfigRes.json(); + setConfigSources(sources); + const sessionSecret = sources?.sessionSecret; + if (!sessionSecret?.value || sessionSecret.value === 'your-secret-key-here') { + w.push('SESSION_SECRET is not set or using a default value. Sessions are insecure.'); + } + } + setWarnings(w); + } + + const jmapUrl = config?.jmapServerUrl || '—'; + + return ( +
+
+

Dashboard

+

Server overview and recent activity

+
+ + {/* Status cards */} +
+ } + label="Application" + value={config?.appName || '—'} + /> + } + label="JMAP Server" + value={jmapUrl ? new URL(jmapUrl).hostname : '—'} + detail={jmapUrl} + /> + } + label="Last Login" + value={status?.lastLogin ? new Date(status.lastLogin).toLocaleString() : 'Never'} + /> +
+ + {/* Feature status row */} +
+ + + + +
+ + {/* Warnings */} + {warnings.map((msg, i) => ( +
+ +

{msg}

+
+ ))} + + {status && !status.lastLogin && ( +
+ +
+

First login detected

+

+ Remember to remove ADMIN_PASSWORD from your .env file now that the hash is stored securely. +

+
+
+ )} + + {/* Recent activity */} +
+

Recent Activity

+
+ {recentActivity.length === 0 ? ( +
+ No activity recorded yet +
+ ) : ( + recentActivity.map((entry, i) => ( +
+
+ + {entry.action} + + + {formatDetail(entry.detail)} + +
+
+ {entry.ip} + {new Date(entry.ts).toLocaleString()} +
+
+ )) + )} +
+
+
+ ); +} + +function StatusCard({ icon, label, value, detail }: { icon: React.ReactNode; label: string; value: string; detail?: string }) { + return ( +
+
+ {icon} + {label} +
+
{value}
+ {detail &&

{detail}

} +
+ ); +} + +function FeaturePill({ label, active }: { label: string; active: boolean }) { + return ( +
+ + {label} + + {active ? 'On' : 'Off'} + +
+ ); +} + +function formatDetail(detail: Record): string { + if (!detail || Object.keys(detail).length === 0) return ''; + if (detail.key) return `${detail.key}: ${detail.old} → ${detail.new}`; + if (detail.reason) return String(detail.reason); + if (detail.changes && Array.isArray(detail.changes)) return `${detail.changes.length} setting(s) changed`; + return JSON.stringify(detail).slice(0, 80); +} diff --git a/app/admin/policy/page.tsx b/app/admin/policy/page.tsx new file mode 100644 index 00000000..e4275296 --- /dev/null +++ b/app/admin/policy/page.tsx @@ -0,0 +1,202 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { Save, Loader2, Lock } from 'lucide-react'; +import type { SettingsPolicy, FeatureGates } from '@/lib/admin/types'; +import { DEFAULT_FEATURE_GATES, DEFAULT_POLICY } from '@/lib/admin/types'; + +const FEATURE_GATE_LABELS: Record = { + sidebarAppsEnabled: { label: 'Sidebar Apps', description: 'Allow custom web apps in navigation rail' }, + userThemesEnabled: { label: 'User Themes', description: 'Allow user-uploaded theme files' }, + settingsExportEnabled: { label: 'Settings Export/Import', description: 'Allow users to export and import settings JSON' }, + customKeywordsEnabled: { label: 'Custom Keywords', description: 'Allow user-created labels and tags' }, + templatesEnabled: { label: 'Email Templates', description: 'Allow email template creation and library' }, + calendarTasksEnabled: { label: 'Calendar Tasks', description: 'Show task panel in calendar view' }, + smimeEnabled: { label: 'S/MIME', description: 'Enable certificate management and email signing' }, + externalContentEnabled: { label: 'External Content', description: 'Allow users to choose external content loading policy' }, + debugModeEnabled: { label: 'Debug Mode', description: 'Allow users to enable debug/diagnostic mode' }, + folderIconsEnabled: { label: 'Folder Icons', description: 'Allow custom folder icon picker' }, + hoverActionsConfigEnabled: { label: 'Hover Actions Config', description: 'Allow users to customize email hover actions' }, +}; + +const RESTRICTABLE_SETTINGS = [ + { key: 'fontSize', label: 'Font Size', category: 'Appearance', type: 'enum', allowedValues: ['small', 'medium', 'large'] }, + { key: 'density', label: 'Density', category: 'Appearance', type: 'enum', allowedValues: ['compact', 'regular', 'spacious'] }, + { key: 'animationsEnabled', label: 'Animations', category: 'Appearance', type: 'boolean' }, + { key: 'markAsReadDelay', label: 'Mark as Read Delay', category: 'Email', type: 'number' }, + { key: 'deleteAction', label: 'Delete Action', category: 'Email', type: 'enum', allowedValues: ['trash', 'permanent'] }, + { key: 'showPreview', label: 'Show Preview', category: 'Email', type: 'boolean' }, + { key: 'emailsPerPage', label: 'Emails Per Page', category: 'Email', type: 'number' }, + { key: 'externalContentPolicy', label: 'External Content Policy', category: 'Email', type: 'enum', allowedValues: ['allow', 'block', 'ask'] }, + { key: 'sendConfirmation', label: 'Send Confirmation', category: 'Composer', type: 'boolean' }, + { key: 'defaultReplyMode', label: 'Default Reply Mode', category: 'Composer', type: 'enum', allowedValues: ['reply', 'reply-all'] }, + { key: 'sessionTimeout', label: 'Session Timeout', category: 'Privacy', type: 'number' }, + { key: 'emailNotificationsEnabled', label: 'Email Notifications', category: 'Notifications', type: 'boolean' }, + { key: 'calendarNotificationsEnabled', label: 'Calendar Notifications', category: 'Notifications', type: 'boolean' }, + { key: 'debugMode', label: 'Debug Mode', category: 'Advanced', type: 'boolean' }, +]; + +export default function AdminPolicyPage() { + const [policy, setPolicy] = useState({ ...DEFAULT_POLICY }); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null); + const [dirty, setDirty] = useState(false); + + useEffect(() => { fetchPolicy(); }, []); + + async function fetchPolicy() { + setLoading(true); + const res = await fetch('/api/admin/policy'); + if (res.ok) setPolicy(await res.json()); + setLoading(false); + } + + function toggleFeature(key: keyof FeatureGates) { + setPolicy(prev => ({ + ...prev, + features: { ...prev.features, [key]: !prev.features[key] }, + })); + setDirty(true); + setMessage(null); + } + + function toggleLocked(settingKey: string) { + setPolicy(prev => { + const existing = prev.restrictions[settingKey] || {}; + const newRestrictions = { ...prev.restrictions }; + if (existing.locked) { + delete newRestrictions[settingKey]; + } else { + newRestrictions[settingKey] = { ...existing, locked: true }; + } + return { ...prev, restrictions: newRestrictions }; + }); + setDirty(true); + setMessage(null); + } + + function toggleHidden(settingKey: string) { + setPolicy(prev => { + const existing = prev.restrictions[settingKey] || {}; + const newRestrictions = { ...prev.restrictions }; + newRestrictions[settingKey] = { ...existing, hidden: !existing.hidden }; + if (!newRestrictions[settingKey].hidden && !newRestrictions[settingKey].locked) { + delete newRestrictions[settingKey]; + } + return { ...prev, restrictions: newRestrictions }; + }); + setDirty(true); + setMessage(null); + } + + async function handleSave() { + setSaving(true); + setMessage(null); + + const res = await fetch('/api/admin/policy', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(policy), + }); + + if (res.ok) { + setMessage({ type: 'success', text: 'Policy saved. Users will see changes on next login.' }); + setDirty(false); + } else { + const data = await res.json(); + setMessage({ type: 'error', text: data.error || 'Failed to save' }); + } + setSaving(false); + } + + if (loading) { + return
Loading...
; + } + + const categories = [...new Set(RESTRICTABLE_SETTINGS.map(s => s.category))]; + + return ( +
+
+
+

User Policy

+

Control which features and settings users can access

+
+ {dirty && ( + + )} +
+ + {message && ( +
+ {message.text} +
+ )} + + {/* Feature Gates */} +
+
+

Feature Gates

+

Toggle entire features on or off for all users

+
+
+ {(Object.keys(DEFAULT_FEATURE_GATES) as (keyof FeatureGates)[]).map(key => { + const { label, description } = FEATURE_GATE_LABELS[key]; + const enabled = policy.features[key]; + return ( +
+
+ {label} +

{description}

+
+ +
+ ); + })} +
+
+ + {/* Setting Restrictions */} + {categories.map(category => ( +
+
+

{category}

+
+
+ {RESTRICTABLE_SETTINGS.filter(s => s.category === category).map(setting => { + const restriction = policy.restrictions[setting.key] || {}; + return ( +
+ {setting.label} +
+ + +
+
+ ); + })} +
+
+ ))} +
+ ); +} diff --git a/app/admin/settings/page.tsx b/app/admin/settings/page.tsx new file mode 100644 index 00000000..4225595f --- /dev/null +++ b/app/admin/settings/page.tsx @@ -0,0 +1,240 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { Save, RotateCcw, Loader2 } from 'lucide-react'; + +interface ConfigEntry { + value: unknown; + source: 'admin' | 'env' | 'default'; +} + +export default function AdminSettingsPage() { + const [config, setConfig] = useState>({}); + const [edits, setEdits] = useState>({}); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null); + + useEffect(() => { + fetchConfig(); + }, []); + + async function fetchConfig() { + setLoading(true); + const res = await fetch('/api/admin/config'); + if (res.ok) { + setConfig(await res.json()); + } + setLoading(false); + } + + function handleChange(key: string, value: unknown) { + setEdits(prev => ({ ...prev, [key]: value })); + setMessage(null); + } + + function currentValue(key: string): unknown { + if (key in edits) return edits[key]; + return config[key]?.value; + } + + async function handleSave() { + if (Object.keys(edits).length === 0) return; + setSaving(true); + setMessage(null); + + const res = await fetch('/api/admin/config', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(edits), + }); + + if (res.ok) { + setMessage({ type: 'success', text: 'Settings saved. Changes take effect on next page load.' }); + setEdits({}); + await fetchConfig(); + } else { + const data = await res.json(); + setMessage({ type: 'error', text: data.error || 'Failed to save' }); + } + setSaving(false); + } + + async function handleRevert(key: string) { + const res = await fetch('/api/admin/config', { + method: 'DELETE', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ key }), + }); + if (res.ok) { + setEdits(prev => { + const next = { ...prev }; + delete next[key]; + return next; + }); + await fetchConfig(); + setMessage({ type: 'success', text: `${key} reverted to default` }); + } + } + + const hasEdits = Object.keys(edits).length > 0; + + if (loading) { + return
Loading...
; + } + + return ( +
+
+
+

Server Settings

+

General server configuration

+
+ {hasEdits && ( + + )} +
+ + {message && ( +
+ {message.text} +
+ )} + + {/* General */} + + + + + + + + + {/* Logging */} + + + + + + {/* Settings Sync */} + + + +
+ ); +} + +function SettingsSection({ title, children }: { title: string; children: React.ReactNode }) { + return ( +
+
+

{title}

+
+
+ {children} +
+
+ ); +} + +function SourceBadge({ source }: { source?: string }) { + if (!source || source === 'default') return null; + return ( + + {source} + + ); +} + +function TextSetting({ label, configKey, value, source, onChange, onRevert, placeholder }: { + label: string; configKey: string; value: string; source?: string; + onChange: (key: string, value: unknown) => void; onRevert: (key: string) => void; placeholder?: string; +}) { + return ( +
+
+ + +
+
+ onChange(configKey, e.target.value)} + placeholder={placeholder} + className="h-8 w-64 rounded-md border border-input bg-background px-2.5 text-sm text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" + /> + {source === 'admin' && ( + + )} +
+
+ ); +} + +function ToggleSetting({ label, description, configKey, value, source, onChange, onRevert }: { + label: string; description?: string; configKey: string; value: boolean; source?: string; + onChange: (key: string, value: unknown) => void; onRevert: (key: string) => void; +}) { + return ( +
+
+
+ {label} + +
+ {description &&

{description}

} +
+
+ + {source === 'admin' && ( + + )} +
+
+ ); +} + +function SelectSetting({ label, configKey, value, source, options, onChange, onRevert }: { + label: string; configKey: string; value: string; source?: string; options: string[]; + onChange: (key: string, value: unknown) => void; onRevert: (key: string) => void; +}) { + return ( +
+
+ {label} + +
+
+ + {source === 'admin' && ( + + )} +
+
+ ); +} diff --git a/app/api/admin/audit/route.ts b/app/api/admin/audit/route.ts new file mode 100644 index 00000000..f48e30c4 --- /dev/null +++ b/app/api/admin/audit/route.ts @@ -0,0 +1,27 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { requireAdminAuth } from '@/lib/admin/session'; +import { readAuditLog } from '@/lib/admin/audit'; +import { logger } from '@/lib/logger'; + +/** + * GET /api/admin/audit — Get paginated audit log entries (admin-protected) + */ +export async function GET(request: NextRequest) { + try { + const result = await requireAdminAuth(); + if ('error' in result) return result.error; + + const page = Math.max(1, parseInt(request.nextUrl.searchParams.get('page') || '1', 10)); + const limit = Math.min(200, Math.max(1, parseInt(request.nextUrl.searchParams.get('limit') || '50', 10))); + const action = request.nextUrl.searchParams.get('action') || undefined; + + const { entries, total } = await readAuditLog(page, limit, action); + + return NextResponse.json({ entries, total, page, limit }, { + headers: { 'Cache-Control': 'no-store' }, + }); + } catch (error) { + logger.error('Audit log read error', { error: error instanceof Error ? error.message : 'Unknown error' }); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} diff --git a/app/api/admin/auth/route.ts b/app/api/admin/auth/route.ts new file mode 100644 index 00000000..635d5e1e --- /dev/null +++ b/app/api/admin/auth/route.ts @@ -0,0 +1,101 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { verifyAdminPassword, updateLastLogin, isAdminEnabled, getAdminMeta } from '@/lib/admin/password'; +import { setAdminSessionCookie, clearAdminSessionCookie, requireAdminAuth, getClientIP } from '@/lib/admin/session'; +import { checkRateLimit } from '@/lib/admin/rate-limit'; +import { auditLog } from '@/lib/admin/audit'; +import { logger } from '@/lib/logger'; + +/** + * POST /api/admin/auth — Login + */ +export async function POST(request: NextRequest) { + try { + if (!isAdminEnabled()) { + return NextResponse.json({ error: 'Admin dashboard is not configured' }, { status: 404 }); + } + + const ip = getClientIP(request); + + // Rate limit check + const limit = checkRateLimit(ip); + if (!limit.allowed) { + const retryAfter = Math.ceil(limit.retryAfterMs / 1000); + await auditLog('admin.login_blocked', { reason: 'rate_limit' }, ip); + return NextResponse.json( + { error: 'Too many login attempts. Try again later.' }, + { status: 429, headers: { 'Retry-After': String(retryAfter) } } + ); + } + + const body = await request.json(); + const { password } = body; + + if (!password || typeof password !== 'string') { + return NextResponse.json({ error: 'Password is required' }, { status: 400 }); + } + + const valid = await verifyAdminPassword(password); + if (!valid) { + await auditLog('admin.login_failed', {}, ip); + logger.warn('Admin login failed', { ip }); + return NextResponse.json({ error: 'Invalid password' }, { status: 401 }); + } + + await setAdminSessionCookie(); + await updateLastLogin(); + await auditLog('admin.login', {}, ip); + + return NextResponse.json({ ok: true }); + } catch (error) { + logger.error('Admin login error', { error: error instanceof Error ? error.message : 'Unknown error' }); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} + +/** + * GET /api/admin/auth — Check session status + */ +export async function GET() { + try { + if (!isAdminEnabled()) { + return NextResponse.json({ enabled: false, authenticated: false }, { + headers: { 'Cache-Control': 'no-store' }, + }); + } + + const result = await requireAdminAuth(); + if ('error' in result) { + return NextResponse.json({ enabled: true, authenticated: false }, { + headers: { 'Cache-Control': 'no-store' }, + }); + } + + const meta = getAdminMeta(); + return NextResponse.json({ + enabled: true, + authenticated: true, + lastLogin: meta?.lastLogin, + passwordChangedAt: meta?.passwordChangedAt, + }, { + headers: { 'Cache-Control': 'no-store' }, + }); + } catch (error) { + logger.error('Admin status error', { error: error instanceof Error ? error.message : 'Unknown error' }); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} + +/** + * DELETE /api/admin/auth — Logout + */ +export async function DELETE(request: NextRequest) { + try { + const ip = getClientIP(request); + await clearAdminSessionCookie(); + await auditLog('admin.logout', {}, ip); + return NextResponse.json({ ok: true }); + } catch (error) { + logger.error('Admin logout error', { error: error instanceof Error ? error.message : 'Unknown error' }); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} diff --git a/app/api/admin/branding/[filename]/route.ts b/app/api/admin/branding/[filename]/route.ts new file mode 100644 index 00000000..62b72b92 --- /dev/null +++ b/app/api/admin/branding/[filename]/route.ts @@ -0,0 +1,66 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { readFile, stat } from 'node:fs/promises'; +import path from 'node:path'; + +const BRANDING_DIR = path.join(process.cwd(), 'data', 'admin', 'branding'); + +const MIME_TYPES: Record = { + '.svg': 'image/svg+xml', + '.png': 'image/png', + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.webp': 'image/webp', + '.ico': 'image/x-icon', +}; + +/** + * GET /api/admin/branding/[filename] — Serve uploaded branding images + * + * This endpoint is public (no admin auth) so browsers can load images. + * Only files in the branding directory are served; directory traversal is prevented. + */ +export async function GET( + _request: NextRequest, + { params }: { params: Promise<{ filename: string }> }, +) { + try { + const { filename } = await params; + + // Sanitize: only allow basename, no path separators + const safe = path.basename(filename); + if (safe !== filename || filename.includes('..')) { + return NextResponse.json({ error: 'Invalid filename' }, { status: 400 }); + } + + const ext = path.extname(safe).toLowerCase(); + const contentType = MIME_TYPES[ext]; + if (!contentType) { + return NextResponse.json({ error: 'Unsupported file type' }, { status: 400 }); + } + + const filePath = path.join(BRANDING_DIR, safe); + + // Ensure resolved path is still within BRANDING_DIR + const resolved = path.resolve(filePath); + if (!resolved.startsWith(path.resolve(BRANDING_DIR))) { + return NextResponse.json({ error: 'Invalid filename' }, { status: 400 }); + } + + const fileStat = await stat(resolved).catch(() => null); + if (!fileStat || !fileStat.isFile()) { + return NextResponse.json({ error: 'Not found' }, { status: 404 }); + } + + const buffer = await readFile(resolved); + + return new NextResponse(buffer, { + headers: { + 'Content-Type': contentType, + 'Cache-Control': 'public, max-age=3600, must-revalidate', + 'Content-Length': String(buffer.length), + }, + }); + } catch { + return NextResponse.json({ error: 'Not found' }, { status: 404 }); + } +} diff --git a/app/api/admin/branding/route.ts b/app/api/admin/branding/route.ts new file mode 100644 index 00000000..e97b0ae0 --- /dev/null +++ b/app/api/admin/branding/route.ts @@ -0,0 +1,146 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { requireAdminAuth, getClientIP } from '@/lib/admin/session'; +import { auditLog } from '@/lib/admin/audit'; +import { configManager } from '@/lib/admin/config-manager'; +import { logger } from '@/lib/logger'; +import { writeFile, unlink, mkdir } from 'node:fs/promises'; +import { existsSync } from 'node:fs'; +import path from 'node:path'; + +const BRANDING_DIR = path.join(process.cwd(), 'data', 'admin', 'branding'); +const MAX_FILE_SIZE = 2 * 1024 * 1024; // 2 MB +const ALLOWED_MIME_TYPES = new Set([ + 'image/svg+xml', + 'image/png', + 'image/jpeg', + 'image/webp', + 'image/x-icon', + 'image/vnd.microsoft.icon', +]); + +/** Slots that correspond to branding config keys */ +const VALID_SLOTS = new Set([ + 'faviconUrl', + 'appLogoLightUrl', + 'appLogoDarkUrl', + 'loginLogoLightUrl', + 'loginLogoDarkUrl', +]); + +function sanitizeFilename(name: string): string { + // Strip directory traversal, keep only safe chars + return path.basename(name).replace(/[^a-zA-Z0-9._-]/g, '_'); +} + +/** + * POST /api/admin/branding — Upload a branding image file + * + * Expects multipart/form-data with: + * - file: the image file + * - slot: which branding field this is for (e.g. "faviconUrl") + */ +export async function POST(request: NextRequest) { + try { + const result = await requireAdminAuth(); + if ('error' in result) return result.error; + + const ip = getClientIP(request); + const formData = await request.formData(); + const file = formData.get('file') as File | null; + const slot = formData.get('slot') as string | null; + + if (!file || !slot) { + return NextResponse.json({ error: 'Missing file or slot' }, { status: 400 }); + } + + if (!VALID_SLOTS.has(slot)) { + return NextResponse.json({ error: `Invalid slot: ${slot}` }, { status: 400 }); + } + + if (file.size > MAX_FILE_SIZE) { + return NextResponse.json({ error: 'File too large (max 2 MB)' }, { status: 400 }); + } + + if (!ALLOWED_MIME_TYPES.has(file.type)) { + return NextResponse.json( + { error: `Unsupported file type: ${file.type}. Allowed: SVG, PNG, JPEG, WebP, ICO` }, + { status: 400 }, + ); + } + + // Determine extension from mime type + const extMap: Record = { + 'image/svg+xml': '.svg', + 'image/png': '.png', + 'image/jpeg': '.jpg', + 'image/webp': '.webp', + 'image/x-icon': '.ico', + 'image/vnd.microsoft.icon': '.ico', + }; + const ext = extMap[file.type] || '.png'; + const safeName = sanitizeFilename(`${slot}${ext}`); + const filePath = path.join(BRANDING_DIR, safeName); + + // Ensure branding directory exists + if (!existsSync(BRANDING_DIR)) { + await mkdir(BRANDING_DIR, { recursive: true }); + } + + // Write file to disk + const buffer = Buffer.from(await file.arrayBuffer()); + await writeFile(filePath, buffer); + + // Update config to point to the served URL + const servedUrl = `/api/admin/branding/${safeName}`; + await configManager.ensureLoaded(); + await configManager.setAdminConfig({ [slot]: servedUrl }); + + await auditLog('branding_upload', { slot, filename: safeName, size: file.size, mimeType: file.type }, ip); + + return NextResponse.json({ url: servedUrl, filename: safeName }); + } catch (error) { + logger.error('Branding upload error', { error: error instanceof Error ? error.message : 'Unknown error' }); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} + +/** + * DELETE /api/admin/branding — Remove an uploaded branding file + * + * Expects JSON body: { slot: string } + */ +export async function DELETE(request: NextRequest) { + try { + const result = await requireAdminAuth(); + if ('error' in result) return result.error; + + const ip = getClientIP(request); + const { slot } = await request.json(); + + if (!slot || !VALID_SLOTS.has(slot)) { + return NextResponse.json({ error: 'Invalid or missing slot' }, { status: 400 }); + } + + // Find and remove matching files for this slot + const possibleExts = ['.svg', '.png', '.jpg', '.webp', '.ico']; + let removed = false; + for (const ext of possibleExts) { + const filePath = path.join(BRANDING_DIR, `${slot}${ext}`); + if (existsSync(filePath)) { + await unlink(filePath); + removed = true; + } + } + + // Clear the config override so it falls back to default/env + await configManager.ensureLoaded(); + await configManager.removeAdminOverride(slot); + + await auditLog('branding_delete', { slot, fileRemoved: removed }, ip); + + return NextResponse.json({ success: true }); + } catch (error) { + logger.error('Branding delete error', { error: error instanceof Error ? error.message : 'Unknown error' }); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} diff --git a/app/api/admin/change-password/route.ts b/app/api/admin/change-password/route.ts new file mode 100644 index 00000000..2247a6c9 --- /dev/null +++ b/app/api/admin/change-password/route.ts @@ -0,0 +1,37 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { changeAdminPassword } from '@/lib/admin/password'; +import { requireAdminAuth, getClientIP } from '@/lib/admin/session'; +import { auditLog } from '@/lib/admin/audit'; +import { logger } from '@/lib/logger'; + +/** + * POST /api/admin/change-password — Change admin password + */ +export async function POST(request: NextRequest) { + try { + const result = await requireAdminAuth(); + if ('error' in result) return result.error; + + const ip = getClientIP(request); + const { currentPassword, newPassword } = await request.json(); + + if (!currentPassword || !newPassword || typeof currentPassword !== 'string' || typeof newPassword !== 'string') { + return NextResponse.json({ error: 'Both current and new password are required' }, { status: 400 }); + } + + if (newPassword.length < 8) { + return NextResponse.json({ error: 'New password must be at least 8 characters' }, { status: 400 }); + } + + const success = await changeAdminPassword(currentPassword, newPassword); + if (!success) { + return NextResponse.json({ error: 'Current password is incorrect' }, { status: 401 }); + } + + await auditLog('admin.change-password', {}, ip); + return NextResponse.json({ ok: true }); + } catch (error) { + logger.error('Admin change password error', { error: error instanceof Error ? error.message : 'Unknown error' }); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} diff --git a/app/api/admin/config/route.ts b/app/api/admin/config/route.ts new file mode 100644 index 00000000..16db5135 --- /dev/null +++ b/app/api/admin/config/route.ts @@ -0,0 +1,94 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { configManager } from '@/lib/admin/config-manager'; +import { requireAdminAuth, getClientIP } from '@/lib/admin/session'; +import { auditLog } from '@/lib/admin/audit'; +import { CONFIG_ENV_MAP } from '@/lib/admin/types'; +import { logger } from '@/lib/logger'; + +/** + * GET /api/admin/config — Get full config with sources (admin-protected) + */ +export async function GET() { + try { + const result = await requireAdminAuth(); + if ('error' in result) return result.error; + + await configManager.ensureLoaded(); + const config = configManager.getAllWithSources(); + + return NextResponse.json(config, { + headers: { 'Cache-Control': 'no-store' }, + }); + } catch (error) { + logger.error('Admin config read error', { error: error instanceof Error ? error.message : 'Unknown error' }); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} + +/** + * PATCH /api/admin/config — Update config overrides (admin-protected) + */ +export async function PATCH(request: NextRequest) { + try { + const result = await requireAdminAuth(); + if ('error' in result) return result.error; + + const ip = getClientIP(request); + const updates = await request.json(); + + if (!updates || typeof updates !== 'object' || Array.isArray(updates)) { + return NextResponse.json({ error: 'Request body must be an object' }, { status: 400 }); + } + + // Validate keys + const validKeys = Object.keys(CONFIG_ENV_MAP); + const invalidKeys = Object.keys(updates).filter(k => !validKeys.includes(k)); + if (invalidKeys.length > 0) { + return NextResponse.json({ error: `Unknown config keys: ${invalidKeys.join(', ')}` }, { status: 400 }); + } + + // Get old values for audit + const oldValues: Record = {}; + for (const key of Object.keys(updates)) { + oldValues[key] = configManager.get(key); + } + + await configManager.setAdminConfig(updates); + await auditLog('config.update', { changes: Object.keys(updates).map(k => ({ key: k, old: oldValues[k], new: updates[k] })) }, ip); + + return NextResponse.json({ ok: true }); + } catch (error) { + logger.error('Admin config update error', { error: error instanceof Error ? error.message : 'Unknown error' }); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} + +/** + * DELETE /api/admin/config — Remove admin override for a key (revert to env/default) + */ +export async function DELETE(request: NextRequest) { + try { + const result = await requireAdminAuth(); + if ('error' in result) return result.error; + + const ip = getClientIP(request); + const { key } = await request.json(); + + if (!key || typeof key !== 'string') { + return NextResponse.json({ error: 'Key is required' }, { status: 400 }); + } + + if (!CONFIG_ENV_MAP[key]) { + return NextResponse.json({ error: `Unknown config key: ${key}` }, { status: 400 }); + } + + const oldValue = configManager.get(key); + await configManager.removeAdminOverride(key); + await auditLog('config.revert', { key, oldValue }, ip); + + return NextResponse.json({ ok: true }); + } catch (error) { + logger.error('Admin config revert error', { error: error instanceof Error ? error.message : 'Unknown error' }); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} diff --git a/app/api/admin/policy/route.ts b/app/api/admin/policy/route.ts new file mode 100644 index 00000000..d2664a8e --- /dev/null +++ b/app/api/admin/policy/route.ts @@ -0,0 +1,55 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { configManager } from '@/lib/admin/config-manager'; +import { requireAdminAuth, getClientIP } from '@/lib/admin/session'; +import { auditLog } from '@/lib/admin/audit'; +import { logger } from '@/lib/logger'; +import type { SettingsPolicy } from '@/lib/admin/types'; + +/** + * GET /api/admin/policy — Get settings policy (NOT admin-protected — users read this) + */ +export async function GET() { + try { + await configManager.ensureLoaded(); + const policy = configManager.getPolicy(); + return NextResponse.json(policy, { + headers: { 'Cache-Control': 'no-store' }, + }); + } catch (error) { + logger.error('Policy read error', { error: error instanceof Error ? error.message : 'Unknown error' }); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} + +/** + * PUT /api/admin/policy — Update settings policy (admin-protected) + */ +export async function PUT(request: NextRequest) { + try { + const result = await requireAdminAuth(); + if ('error' in result) return result.error; + + const ip = getClientIP(request); + const policy = await request.json() as SettingsPolicy; + + if (!policy || typeof policy !== 'object') { + return NextResponse.json({ error: 'Invalid policy object' }, { status: 400 }); + } + + // Basic validation + if (policy.restrictions && typeof policy.restrictions !== 'object') { + return NextResponse.json({ error: 'restrictions must be an object' }, { status: 400 }); + } + if (policy.features && typeof policy.features !== 'object') { + return NextResponse.json({ error: 'features must be an object' }, { status: 400 }); + } + + await configManager.setPolicy(policy); + await auditLog('policy.update', { restrictionCount: Object.keys(policy.restrictions || {}).length }, ip); + + return NextResponse.json({ ok: true }); + } catch (error) { + logger.error('Policy update error', { error: error instanceof Error ? error.message : 'Unknown error' }); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} diff --git a/app/api/config/route.ts b/app/api/config/route.ts index e27294b6..c054c093 100644 --- a/app/api/config/route.ts +++ b/app/api/config/route.ts @@ -1,43 +1,54 @@ import { NextResponse } from 'next/server'; import { logger } from '@/lib/logger'; +import { configManager } from '@/lib/admin/config-manager'; /** * Runtime configuration endpoint * * This endpoint serves configuration values that can be set at runtime - * via environment variables, enabling post-build configuration for - * Docker deployments. + * via environment variables or admin dashboard overrides, enabling + * post-build configuration for Docker deployments. * * Priority order: - * 1. Runtime env vars (APP_NAME, JMAP_SERVER_URL) - * 2. Build-time env vars (NEXT_PUBLIC_APP_NAME, NEXT_PUBLIC_JMAP_SERVER_URL) - * 3. Default values + * 1. Admin dashboard overrides (data/admin/config.json) + * 2. Runtime env vars (APP_NAME, JMAP_SERVER_URL) + * 3. Build-time env vars (NEXT_PUBLIC_APP_NAME, NEXT_PUBLIC_JMAP_SERVER_URL) + * 4. Default values */ export async function GET() { logger.debug('Config requested'); + await configManager.ensureLoaded(); + + const appName = configManager.get('appName') || process.env.NEXT_PUBLIC_APP_NAME || 'Webmail'; + const jmapServerUrl = configManager.get('jmapServerUrl') || process.env.NEXT_PUBLIC_JMAP_SERVER_URL || ''; + const oauthEnabled = configManager.get('oauthEnabled', false); + const oauthOnly = oauthEnabled && configManager.get('oauthOnly', false); + const stalwartFeaturesEnabled = configManager.get('stalwartFeaturesEnabled', true); + const allowedFrameAncestors = configManager.get('allowedFrameAncestors', ''); + return NextResponse.json({ - appName: process.env.APP_NAME || process.env.NEXT_PUBLIC_APP_NAME || 'Webmail', - jmapServerUrl: process.env.JMAP_SERVER_URL || process.env.NEXT_PUBLIC_JMAP_SERVER_URL || '', - oauthEnabled: process.env.OAUTH_ENABLED === 'true', - oauthOnly: process.env.OAUTH_ENABLED === 'true' && process.env.OAUTH_ONLY === 'true', - oauthClientId: process.env.OAUTH_CLIENT_ID || '', - oauthIssuerUrl: process.env.OAUTH_ISSUER_URL || '', + appName, + jmapServerUrl, + oauthEnabled, + oauthOnly, + oauthClientId: configManager.get('oauthClientId', ''), + oauthIssuerUrl: configManager.get('oauthIssuerUrl', ''), rememberMeEnabled: !!process.env.SESSION_SECRET, - settingsSyncEnabled: process.env.SETTINGS_SYNC_ENABLED === 'true' && !!process.env.SESSION_SECRET, - stalwartFeaturesEnabled: process.env.STALWART_FEATURES !== 'false', - devMode: process.env.DEV_MOCK_JMAP === 'true', - faviconUrl: process.env.FAVICON_URL || '/branding/Bulwark_Favicon.svg', - appLogoLightUrl: process.env.APP_LOGO_LIGHT_URL || '', - appLogoDarkUrl: process.env.APP_LOGO_DARK_URL || '', - loginLogoLightUrl: process.env.LOGIN_LOGO_LIGHT_URL || '/branding/Bulwark_Logo_Color.svg', - loginLogoDarkUrl: process.env.LOGIN_LOGO_DARK_URL || '/branding/Bulwark_Logo_White.svg', - loginCompanyName: process.env.LOGIN_COMPANY_NAME || '', - loginImprintUrl: process.env.LOGIN_IMPRINT_URL || '', - loginPrivacyPolicyUrl: process.env.LOGIN_PRIVACY_POLICY_URL || '', - loginWebsiteUrl: process.env.LOGIN_WEBSITE_URL || '', - demoMode: process.env.DEMO_MODE === 'true', - autoSsoEnabled: process.env.AUTO_SSO_ENABLED === 'true', - embeddedMode: !!process.env.ALLOWED_FRAME_ANCESTORS && process.env.ALLOWED_FRAME_ANCESTORS !== "'none'", - parentOrigin: process.env.NEXT_PUBLIC_PARENT_ORIGIN || '', + settingsSyncEnabled: configManager.get('settingsSyncEnabled', false) && !!process.env.SESSION_SECRET, + stalwartFeaturesEnabled, + devMode: configManager.get('devMode', false), + faviconUrl: configManager.get('faviconUrl', '/branding/Bulwark_Favicon.svg'), + appLogoLightUrl: configManager.get('appLogoLightUrl', ''), + appLogoDarkUrl: configManager.get('appLogoDarkUrl', ''), + loginLogoLightUrl: configManager.get('loginLogoLightUrl', '/branding/Bulwark_Logo_Color.svg'), + loginLogoDarkUrl: configManager.get('loginLogoDarkUrl', '/branding/Bulwark_Logo_White.svg'), + loginCompanyName: configManager.get('loginCompanyName', ''), + loginImprintUrl: configManager.get('loginImprintUrl', ''), + loginPrivacyPolicyUrl: configManager.get('loginPrivacyPolicyUrl', ''), + loginWebsiteUrl: configManager.get('loginWebsiteUrl', ''), + demoMode: configManager.get('demoMode', false), + autoSsoEnabled: configManager.get('autoSsoEnabled', false), + embeddedMode: !!allowedFrameAncestors && allowedFrameAncestors !== "'none'", + parentOrigin: configManager.get('parentOrigin', ''), }); } diff --git a/app/api/settings/route.ts b/app/api/settings/route.ts index 5b2f95c1..1c96b622 100644 --- a/app/api/settings/route.ts +++ b/app/api/settings/route.ts @@ -4,6 +4,7 @@ import { logger } from '@/lib/logger'; import { decryptSession } from '@/lib/auth/crypto'; import { sessionCookieName } from '@/lib/auth/session-cookie'; import { saveUserSettings, loadUserSettings, deleteUserSettings } from '@/lib/settings-sync'; +import { configManager } from '@/lib/admin/config-manager'; function isEnabled(): boolean { return process.env.SETTINGS_SYNC_ENABLED === 'true' && !!process.env.SESSION_SECRET; @@ -81,7 +82,18 @@ export async function POST(request: NextRequest) { return NextResponse.json({ error: 'Identity mismatch' }, { status: 403 }); } - await saveUserSettings(username, serverUrl, settings); + // Enforce admin policy — strip locked settings so users can't override them + await configManager.ensureLoaded(); + const policy = configManager.getPolicy(); + const filteredSettings = { ...settings }; + for (const key of Object.keys(filteredSettings)) { + const restriction = policy.restrictions[key]; + if (restriction?.locked) { + delete filteredSettings[key]; + } + } + + await saveUserSettings(username, serverUrl, filteredSettings); return NextResponse.json({ ok: true }); } catch (error) { const message = error instanceof Error ? error.message : 'Unknown error'; diff --git a/app/not-found.tsx b/app/not-found.tsx index c1d0dd64..7d4d30f9 100644 --- a/app/not-found.tsx +++ b/app/not-found.tsx @@ -8,12 +8,19 @@ export default function NotFound() { useEffect(() => { if (!isAuthenticated) { - window.location.href = "/login"; + // Don't redirect admin routes to the webmail login page + const isAdminRoute = window.location.pathname === '/admin' || window.location.pathname.startsWith('/admin/'); + if (!isAdminRoute) { + window.location.href = "/login"; + } } }, [isAuthenticated]); if (!isAuthenticated) { - return null; + // Allow admin routes to render the 404 without redirecting + const isAdmin = typeof window !== 'undefined' && + (window.location.pathname === '/admin' || window.location.pathname.startsWith('/admin/')); + if (!isAdmin) return null; } return ( diff --git a/components/email/email-composer.tsx b/components/email/email-composer.tsx index c8f865af..5de479d8 100644 --- a/components/email/email-composer.tsx +++ b/components/email/email-composer.tsx @@ -18,6 +18,7 @@ import { useSettingsStore } from "@/stores/settings-store"; import { buildMimeMessage, wrapCmsAsSmimeMessage } from "@/lib/smime/mime-builder"; import type { MimeAttachment } from "@/lib/smime/mime-builder"; import { smimeSign } from "@/lib/smime/smime-sign"; +import { PluginSlot } from "@/components/plugins/plugin-slot"; import { smimeEncrypt } from "@/lib/smime/smime-encrypt"; import { useContactStore } from "@/stores/contact-store"; import { useTemplateStore } from "@/stores/template-store"; @@ -1237,6 +1238,7 @@ export function EmailComposer({ )} + {/* Right side - Discard + Send (desktop) */} diff --git a/components/email/email-context-menu.tsx b/components/email/email-context-menu.tsx index 34c99575..8f4ccb63 100644 --- a/components/email/email-context-menu.tsx +++ b/components/email/email-context-menu.tsx @@ -9,6 +9,7 @@ import { ContextMenuSubMenu, ContextMenuHeader, } from "@/components/ui/context-menu"; +import { PluginSlot } from "@/components/plugins/plugin-slot"; import { Reply, ReplyAll, @@ -366,6 +367,8 @@ export function EmailContextMenu({ ) } /> + + ); } diff --git a/components/email/email-viewer.tsx b/components/email/email-viewer.tsx index f7a833f5..75be5bd6 100644 --- a/components/email/email-viewer.tsx +++ b/components/email/email-viewer.tsx @@ -95,6 +95,7 @@ import type { SmimeStatus } from "@/lib/smime/types"; import { parseTnef, isTnefAttachment } from "@/lib/tnef"; import { debug } from "@/lib/debug"; import type { TnefAttachment } from "@/lib/tnef"; +import { PluginSlot } from "@/components/plugins/plugin-slot"; interface EmailViewerProps { email: Email | null; @@ -2819,6 +2820,7 @@ export function EmailViewer({ {showToolbarLabels && {t('forward')}} )} + {/* Right: Organize actions — order: archive, delete, move, star, tag, spam, read state, print, view source */} @@ -4443,6 +4445,8 @@ export function EmailViewer({
+ + {/* Email Body */}
{effectiveEmailContent.isHtml ? ( @@ -4470,6 +4474,8 @@ export function EmailViewer({ )}
+ + {/* Quick Reply Section - hidden for drafts */} {!isDraft && (
s.resolvedTheme); const { supportsCalendar } = useCalendarStore(); const { mailboxes } = useEmailStore(); const { supportsWebDAV } = useWebDAVStore(); const sidebarApps = useSettingsStore((s) => s.sidebarApps); + const sidebarAppsEnabled = usePolicyStore((s) => s.isFeatureEnabled('sidebarAppsEnabled')); + const visibleSidebarApps = sidebarAppsEnabled ? sidebarApps : []; const inboxUnread = mailboxes.find(m => m.role === "inbox")?.unreadEmails || 0; const navItems: NavItem[] = [ @@ -218,7 +226,7 @@ export function NavigationRail({ })} {/* Custom sidebar apps (per-app mobile visibility) */} - {sidebarApps.filter((app) => app.showOnMobile).map((app) => { + {visibleSidebarApps.filter((app) => app.showOnMobile).map((app) => { const AppIcon = lucideIcons[app.icon as keyof typeof lucideIcons] as LucideIcon | undefined; const isActive = activeAppId === app.id; return ( @@ -287,6 +295,19 @@ export function NavigationRail({ className )} > + {(() => { + const logoUrl = resolvedTheme === 'dark' ? (appLogoDarkUrl || appLogoLightUrl) : (appLogoLightUrl || appLogoDarkUrl); + return logoUrl ? ( +
+ +
+ ) : null; + })()} + + + {/* Footer: Settings + Help + Storage Quota + Sign Out + Push Status */}
s.resolvedTheme); const [expandedFolders, setExpandedFolders] = useState>(new Set()); const [tagsExpanded, setTagsExpanded] = useState(() => { try { @@ -532,17 +529,6 @@ export function Sidebar({ - {(() => { - const logoUrl = resolvedTheme === 'dark' ? (appLogoDarkUrl || appLogoLightUrl) : (appLogoLightUrl || appLogoDarkUrl); - return logoUrl ? ( - - ) : null; - })()} - + + + ); +} + +// ─── Plugin Card ───────────────────────────────────────────── + +interface PluginCardProps { + plugin: InstalledPlugin; + isExpanded: boolean; + onToggleExpand: () => void; + onToggle: () => void; + onUninstall: () => void; + onUpdateSettings: (settings: Record) => void; +} + +function PluginCard({ plugin, isExpanded, onToggleExpand, onToggle, onUninstall, onUpdateSettings }: PluginCardProps) { + return ( +
+ {/* Header */} +
+
+
+ {plugin.name} + + {plugin.status} + +
+
+ {plugin.author} + v{plugin.version} + {plugin.type} +
+
+ +
+ +
+
+ + {/* Expanded Details */} + {isExpanded && ( +
+ {/* Description */} + {plugin.description && ( +

{plugin.description}

+ )} + + {/* Error */} + {plugin.error && ( +
+ + {plugin.error} +
+ )} + + {/* Permissions */} + {plugin.permissions.length > 0 && ( +
+ Permissions: +
+ {plugin.permissions.map(perm => ( + + {perm} + + ))} +
+
+ )} + + {/* Settings (auto-generated from schema) */} + {plugin.settingsSchema && Object.keys(plugin.settingsSchema).length > 0 && ( +
+ Settings: + {Object.entries(plugin.settingsSchema).map(([key, schema]) => ( + onUpdateSettings({ [key]: value })} + /> + ))} +
+ )} + + {/* Uninstall */} +
+ +
+
+ )} +
+ ); +} + +// ─── 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 ( +
+
+ {schema.label} + {schema.description &&

{schema.description}

} +
+ onChange(v)} /> +
+ ); + + case 'select': + return ( +
+
+ {schema.label} + {schema.description &&

{schema.description}

} +
+ +
+ ); + + case 'string': + return ( +
+ {schema.label} + {schema.description &&

{schema.description}

} + onChange(e.target.value)} + className="mt-1 w-full text-xs bg-background border border-border rounded px-2 py-1 text-foreground" + /> +
+ ); + + case 'number': + return ( +
+
+ {schema.label} + {schema.description &&

{schema.description}

} +
+ onChange(Number(e.target.value))} + className="w-20 text-xs bg-background border border-border rounded px-2 py-1 text-foreground" + /> +
+ ); + + default: + return null; + } +} diff --git a/components/settings/settings-section.tsx b/components/settings/settings-section.tsx index f2d23c8a..63b456b4 100644 --- a/components/settings/settings-section.tsx +++ b/components/settings/settings-section.tsx @@ -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 ( -
+
- +
+ + {locked && } +
{description && (

{description}

)}
-
{children}
+
{children}
); } diff --git a/components/settings/themes-settings.tsx b/components/settings/themes-settings.tsx new file mode 100644 index 00000000..b8943890 --- /dev/null +++ b/components/settings/themes-settings.tsx @@ -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(null); + + const handleUpload = async (e: React.ChangeEvent) => { + 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 ( + + {/* Theme Grid */} +
+ {/* Default theme card */} + handleActivate(null)} + /> + + {/* Installed themes */} + {installedThemes.map(theme => ( + handleActivate(theme.id)} + onRemove={!theme.builtIn ? () => handleUninstall(theme) : undefined} + /> + ))} +
+ + {/* Upload */} + + + + +
+ ); +} + +// ─── 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 ( + + )} + + ); +} diff --git a/hooks/use-config.ts b/hooks/use-config.ts index a3376f34..c7f9a004 100644 --- a/hooks/use-config.ts +++ b/hooks/use-config.ts @@ -1,6 +1,7 @@ "use client"; import { useState, useEffect } from 'react'; +import { usePolicyStore } from '@/stores/policy-store'; interface ConfigData { appName: string; @@ -57,6 +58,8 @@ export async function fetchConfig(): Promise { }) .then((data) => { configCache = data; + // Fetch admin policy alongside config (non-blocking) + usePolicyStore.getState().fetchPolicy(); return data; }) .finally(() => { diff --git a/instrumentation.node.ts b/instrumentation.node.ts index ceaeed5e..677e257e 100644 --- a/instrumentation.node.ts +++ b/instrumentation.node.ts @@ -1,4 +1,6 @@ import { readFileSync } from "fs"; +import { configManager } from "./lib/admin/config-manager"; +import { initAdminPassword } from "./lib/admin/password"; const VERSION_CHECK_URL = "https://raw.githubusercontent.com/bulwarkmail/webmail/main/VERSION"; @@ -42,3 +44,13 @@ if (process.env.NODE_ENV === "production") { }) .catch(() => {}); } + +// Initialize admin config and password bootstrap +configManager.load() + .then(() => initAdminPassword()) + .then(() => { + console.info("Admin dashboard initialized"); + }) + .catch((err) => { + console.warn("Admin dashboard init skipped:", err instanceof Error ? err.message : err); + }); diff --git a/lib/__tests__/builtin-themes.test.ts b/lib/__tests__/builtin-themes.test.ts new file mode 100644 index 00000000..69ae9468 --- /dev/null +++ b/lib/__tests__/builtin-themes.test.ts @@ -0,0 +1,52 @@ +import { describe, it, expect } from 'vitest'; +import { BUILTIN_THEMES } from '../builtin-themes'; + +describe('BUILTIN_THEMES', () => { + it('contains exactly 3 themes', () => { + expect(BUILTIN_THEMES).toHaveLength(3); + }); + + it('all themes have required fields', () => { + for (const theme of BUILTIN_THEMES) { + expect(theme.id).toBeTruthy(); + expect(theme.name).toBeTruthy(); + expect(theme.version).toBeTruthy(); + expect(theme.author).toBe('Built-in'); + expect(theme.css).toBeTruthy(); + expect(theme.variants).toEqual(['light', 'dark']); + expect(theme.enabled).toBe(true); + expect(theme.builtIn).toBe(true); + } + }); + + it('all IDs are prefixed with builtin-', () => { + for (const theme of BUILTIN_THEMES) { + expect(theme.id).toMatch(/^builtin-/); + } + }); + + it('all themes have both :root and .dark selectors', () => { + for (const theme of BUILTIN_THEMES) { + expect(theme.css).toContain(':root'); + expect(theme.css).toContain('.dark'); + } + }); + + it('all themes set --color-primary', () => { + for (const theme of BUILTIN_THEMES) { + expect(theme.css).toContain('--color-primary:'); + } + }); + + it('themes have correct names', () => { + const names = BUILTIN_THEMES.map(t => t.name); + expect(names).toContain('Nord'); + expect(names).toContain('Catppuccin'); + expect(names).toContain('Solarized'); + }); + + it('theme IDs are unique', () => { + const ids = BUILTIN_THEMES.map(t => t.id); + expect(new Set(ids).size).toBe(ids.length); + }); +}); diff --git a/lib/__tests__/plugin-api.test.ts b/lib/__tests__/plugin-api.test.ts new file mode 100644 index 00000000..67f1d6a7 --- /dev/null +++ b/lib/__tests__/plugin-api.test.ts @@ -0,0 +1,150 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { createPluginAPI, setSlotRegistrationBridge } from '../plugin-api'; +import type { InstalledPlugin } from '../plugin-types'; +import { clearAllHooks } from '../plugin-hooks'; + +function makePlugin(overrides: Partial = {}): InstalledPlugin { + return { + id: 'test-plugin', + name: 'Test Plugin', + version: '1.0.0', + author: 'Test', + description: '', + type: 'ui-extension', + entrypoint: 'index.js', + permissions: [], + enabled: true, + status: 'running', + settings: {}, + ...overrides, + }; +} + +beforeEach(() => { + clearAllHooks(); + localStorage.clear(); + setSlotRegistrationBridge(null); +}); + +describe('createPluginAPI', () => { + it('exposes plugin info', () => { + const plugin = makePlugin(); + const api = createPluginAPI(plugin); + expect(api.plugin.id).toBe('test-plugin'); + expect(api.plugin.version).toBe('1.0.0'); + }); + + it('returns a frozen copy of settings', () => { + const plugin = makePlugin({ settings: { key: 'val' } }); + const api = createPluginAPI(plugin); + expect(api.plugin.settings).toEqual({ key: 'val' }); + }); +}); + +describe('plugin storage (scoped localStorage)', () => { + it('set and get a value', () => { + const api = createPluginAPI(makePlugin()); + api.storage.set('foo', 42); + expect(api.storage.get('foo')).toBe(42); + }); + + it('scopes to plugin id', () => { + const api1 = createPluginAPI(makePlugin({ id: 'p1' })); + const api2 = createPluginAPI(makePlugin({ id: 'p2' })); + api1.storage.set('key', 'a'); + api2.storage.set('key', 'b'); + expect(api1.storage.get('key')).toBe('a'); + expect(api2.storage.get('key')).toBe('b'); + }); + + it('remove deletes a value', () => { + const api = createPluginAPI(makePlugin()); + api.storage.set('x', 10); + api.storage.remove('x'); + expect(api.storage.get('x')).toBeNull(); + }); + + it('keys lists only plugin-scoped keys', () => { + const api = createPluginAPI(makePlugin({ id: 'kp' })); + api.storage.set('a', 1); + api.storage.set('b', 2); + localStorage.setItem('unrelated', 'val'); + expect(api.storage.keys()).toContain('a'); + expect(api.storage.keys()).toContain('b'); + expect(api.storage.keys()).not.toContain('unrelated'); + }); +}); + +describe('plugin logger', () => { + it('prefixes log messages with plugin id', () => { + const infoSpy = vi.spyOn(console, 'info').mockImplementation(() => {}); + const api = createPluginAPI(makePlugin({ id: 'log-test' })); + api.log.info('hello'); + expect(infoSpy).toHaveBeenCalledWith('[plugin:log-test]', 'hello'); + infoSpy.mockRestore(); + }); +}); + +describe('hooks permission gating', () => { + it('returns no-op disposable without permission', () => { + const plugin = makePlugin({ permissions: [] }); // no email:read + const api = createPluginAPI(plugin); + const d = api.hooks.onEmailOpen(vi.fn()); + expect(d).toBeDefined(); + expect(d.dispose).toBeInstanceOf(Function); + }); + + it('registers handler when permission is granted', () => { + const plugin = makePlugin({ permissions: ['email:read'] }); + const api = createPluginAPI(plugin); + const fn = vi.fn(); + const d = api.hooks.onEmailOpen(fn); + expect(d).toBeDefined(); + d.dispose(); // should not throw + }); +}); + +describe('ui permission requirement', () => { + it('throws without ui:toolbar permission', () => { + const plugin = makePlugin({ permissions: [] }); + const api = createPluginAPI(plugin); + expect(() => api.ui.registerToolbarAction({ + id: 'test', + label: 'Test', + onClick: () => {}, + })).toThrow('lacks permission'); + }); + + it('does not throw with correct permission (slot bridge not set, returns no-op)', () => { + const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const plugin = makePlugin({ permissions: ['ui:toolbar'] }); + const api = createPluginAPI(plugin); + const d = api.ui.registerToolbarAction({ id: 'test', label: 'Test', onClick: () => {} }); + expect(d.dispose).toBeInstanceOf(Function); + consoleSpy.mockRestore(); + }); +}); + +describe('slot registration bridge', () => { + it('calls bridge when set', () => { + const bridge = vi.fn((_name, _reg) => ({ dispose: () => {} })); + setSlotRegistrationBridge(bridge); + + const plugin = makePlugin({ permissions: ['ui:email-footer'] }); + const api = createPluginAPI(plugin); + const DummyComponent = () => null; + api.ui.registerEmailFooter(DummyComponent); + expect(bridge).toHaveBeenCalled(); + }); +}); + +describe('toast bridge', () => { + it('exposes success/error/info/warning methods', () => { + const plugin = makePlugin(); + const api = createPluginAPI(plugin); + expect(api.toast.success).toBeInstanceOf(Function); + expect(api.toast.error).toBeInstanceOf(Function); + expect(api.toast.info).toBeInstanceOf(Function); + expect(api.toast.warning).toBeInstanceOf(Function); + }); +}); diff --git a/lib/__tests__/plugin-hooks.test.ts b/lib/__tests__/plugin-hooks.test.ts new file mode 100644 index 00000000..eaa584e6 --- /dev/null +++ b/lib/__tests__/plugin-hooks.test.ts @@ -0,0 +1,249 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { HookBus, pluginErrorTracker, removeAllPluginHooks, clearAllHooks, emailHooks, calendarHooks } from '../plugin-hooks'; + +beforeEach(() => { + pluginErrorTracker.resetAll(); + clearAllHooks(); +}); + +describe('HookBus', () => { + describe('register / size / dispose', () => { + it('registers a handler', () => { + const bus = new HookBus(); + bus.register('p1', vi.fn()); + expect(bus.size).toBe(1); + }); + + it('dispose removes the handler', () => { + const bus = new HookBus(); + const d = bus.register('p1', vi.fn()); + d.dispose(); + expect(bus.size).toBe(0); + }); + + it('registering multiple handlers', () => { + const bus = new HookBus(); + bus.register('p1', vi.fn()); + bus.register('p2', vi.fn()); + expect(bus.size).toBe(2); + }); + + it('removePlugin removes all handlers for that plugin', () => { + const bus = new HookBus(); + bus.register('p1', vi.fn()); + bus.register('p1', vi.fn()); + bus.register('p2', vi.fn()); + bus.removePlugin('p1'); + expect(bus.size).toBe(1); + }); + + it('clear removes all handlers', () => { + const bus = new HookBus(); + bus.register('p1', vi.fn()); + bus.register('p2', vi.fn()); + bus.clear(); + expect(bus.size).toBe(0); + }); + }); + + describe('emit (observer)', () => { + it('calls all handlers with args', async () => { + const bus = new HookBus<(x: number) => void>(); + const fn1 = vi.fn(); + const fn2 = vi.fn(); + bus.register('p1', fn1); + bus.register('p2', fn2); + await bus.emit(42); + expect(fn1).toHaveBeenCalledWith(42); + expect(fn2).toHaveBeenCalledWith(42); + }); + + it('calls handlers in order', async () => { + const bus = new HookBus<() => void>(); + const order: number[] = []; + bus.register('p1', () => order.push(200), 200); + bus.register('p2', () => order.push(50), 50); + bus.register('p3', () => order.push(100), 100); + await bus.emit(); + expect(order).toEqual([50, 100, 200]); + }); + + it('catches handler errors and records them', async () => { + const bus = new HookBus<() => void>(); + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + bus.register('p1', () => { throw new Error('fail'); }); + await bus.emit(); + expect(consoleSpy).toHaveBeenCalled(); + consoleSpy.mockRestore(); + }); + + it('skips disabled plugins', async () => { + const bus = new HookBus<() => void>(); + const fn = vi.fn(); + bus.register('p1', fn); + + // Manually trigger circuit breaker + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + for (let i = 0; i < 3; i++) { + pluginErrorTracker.record('p1', new Error('test')); + } + consoleSpy.mockRestore(); + + expect(pluginErrorTracker.isDisabled('p1')).toBe(true); + await bus.emit(); + expect(fn).not.toHaveBeenCalled(); + }); + }); + + describe('emitSync', () => { + it('calls handlers synchronously', () => { + const bus = new HookBus<(x: string) => void>(); + const fn = vi.fn(); + bus.register('p1', fn); + bus.emitSync('hello'); + expect(fn).toHaveBeenCalledWith('hello'); + }); + + it('catches errors without throwing', () => { + const bus = new HookBus<() => void>(); + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + bus.register('p1', () => { throw new Error('boom'); }); + expect(() => bus.emitSync()).not.toThrow(); + consoleSpy.mockRestore(); + }); + }); + + describe('intercept', () => { + it('returns true when all handlers pass', async () => { + const bus = new HookBus<() => boolean>(); + bus.register('p1', () => true); + bus.register('p2', () => true); + const result = await bus.intercept(); + expect(result).toBe(true); + }); + + it('returns false when any handler returns false', async () => { + const bus = new HookBus<() => boolean>(); + bus.register('p1', () => true); + bus.register('p2', () => false); + const result = await bus.intercept(); + expect(result).toBe(false); + }); + + it('stops early on false (short-circuits)', async () => { + const bus = new HookBus<() => boolean>(); + const fn3 = vi.fn(() => true); + bus.register('p1', () => true, 10); + bus.register('p2', () => false, 20); + bus.register('p3', fn3, 30); + await bus.intercept(); + expect(fn3).not.toHaveBeenCalled(); + }); + + it('returns true when no handlers registered', async () => { + const bus = new HookBus<() => boolean>(); + expect(await bus.intercept()).toBe(true); + }); + }); + + describe('transform', () => { + it('chains values through handlers', async () => { + const bus = new HookBus<(val: number) => number>(); + bus.register('p1', (val: number) => val * 2); + bus.register('p2', (val: number) => val + 1); + const result = await bus.transform(5); + expect(result).toBe(11); // (5 * 2) + 1 + }); + + it('returns initial value when no handlers', async () => { + const bus = new HookBus<(val: string) => string>(); + const result = await bus.transform('hello'); + expect(result).toBe('hello'); + }); + + it('skips handler that returns undefined', async () => { + const bus = new HookBus<(val: number) => number | undefined>(); + bus.register('p1', () => undefined); + bus.register('p2', (val: number) => val + 10); + const result = await bus.transform(5); + expect(result).toBe(15); + }); + }); +}); + +describe('PluginErrorTracker', () => { + it('is not disabled initially', () => { + expect(pluginErrorTracker.isDisabled('some-plugin')).toBe(false); + }); + + it('disables after threshold errors', () => { + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + pluginErrorTracker.record('p1', new Error('1')); + pluginErrorTracker.record('p1', new Error('2')); + expect(pluginErrorTracker.isDisabled('p1')).toBe(false); + pluginErrorTracker.record('p1', new Error('3')); + expect(pluginErrorTracker.isDisabled('p1')).toBe(true); + consoleSpy.mockRestore(); + }); + + it('calls auto-disable callback', () => { + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const cb = vi.fn(); + pluginErrorTracker.setAutoDisableCallback(cb); + for (let i = 0; i < 3; i++) { + pluginErrorTracker.record('p2', new Error(`err-${i}`)); + } + expect(cb).toHaveBeenCalledWith('p2', expect.any(Error)); + consoleSpy.mockRestore(); + pluginErrorTracker.setAutoDisableCallback(() => {}); + }); + + it('reset re-enables a plugin', () => { + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + for (let i = 0; i < 3; i++) { + pluginErrorTracker.record('p3', new Error(`err-${i}`)); + } + expect(pluginErrorTracker.isDisabled('p3')).toBe(true); + pluginErrorTracker.reset('p3'); + expect(pluginErrorTracker.isDisabled('p3')).toBe(false); + consoleSpy.mockRestore(); + }); +}); + +describe('Hook domain instances', () => { + it('emailHooks has expected buses', () => { + expect(emailHooks.onEmailOpen).toBeInstanceOf(HookBus); + expect(emailHooks.onBeforeEmailSend).toBeInstanceOf(HookBus); + expect(emailHooks.onAfterEmailDelete).toBeInstanceOf(HookBus); + expect(emailHooks.onNewEmailReceived).toBeInstanceOf(HookBus); + }); + + it('calendarHooks has expected buses', () => { + expect(calendarHooks.onCalendarEventOpen).toBeInstanceOf(HookBus); + expect(calendarHooks.onBeforeEventCreate).toBeInstanceOf(HookBus); + expect(calendarHooks.onEventRsvp).toBeInstanceOf(HookBus); + }); +}); + +describe('removeAllPluginHooks', () => { + it('removes handlers from all buses for a plugin', () => { + emailHooks.onEmailOpen.register('test-p', vi.fn()); + calendarHooks.onCalendarEventOpen.register('test-p', vi.fn()); + emailHooks.onEmailOpen.register('other-p', vi.fn()); + + removeAllPluginHooks('test-p'); + + expect(emailHooks.onEmailOpen.size).toBe(1); // other-p remains + expect(calendarHooks.onCalendarEventOpen.size).toBe(0); + }); +}); + +describe('clearAllHooks', () => { + it('removes all handlers from all buses', () => { + emailHooks.onEmailOpen.register('p1', vi.fn()); + calendarHooks.onCalendarEventOpen.register('p2', vi.fn()); + clearAllHooks(); + expect(emailHooks.onEmailOpen.size).toBe(0); + expect(calendarHooks.onCalendarEventOpen.size).toBe(0); + }); +}); diff --git a/lib/__tests__/plugin-loader.test.ts b/lib/__tests__/plugin-loader.test.ts new file mode 100644 index 00000000..db950f6d --- /dev/null +++ b/lib/__tests__/plugin-loader.test.ts @@ -0,0 +1,45 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { + exposePluginExternals, + deactivatePlugin, + isPluginActive, + deactivateAllPlugins, +} from '../plugin-loader'; +import { clearAllHooks, pluginErrorTracker } from '../plugin-hooks'; + +beforeEach(() => { + clearAllHooks(); + pluginErrorTracker.resetAll(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + delete (globalThis as any).__PLUGIN_EXTERNALS__; +}); + +describe('exposePluginExternals', () => { + it('sets window.__PLUGIN_EXTERNALS__ with React, ReactDOM, ReactJSX', () => { + exposePluginExternals(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const externals = (globalThis as any).__PLUGIN_EXTERNALS__; + expect(externals).toBeDefined(); + expect(externals.React).toBeDefined(); + expect(externals.ReactDOM).toBeDefined(); + expect(externals.ReactJSX).toBeDefined(); + }); +}); + +describe('isPluginActive', () => { + it('returns false for unknown plugin', () => { + expect(isPluginActive('nonexistent')).toBe(false); + }); +}); + +describe('deactivatePlugin', () => { + it('does nothing for unknown plugin (no error)', () => { + expect(() => deactivatePlugin('nonexistent')).not.toThrow(); + }); +}); + +describe('deactivateAllPlugins', () => { + it('does not throw when no plugins active', () => { + expect(() => deactivateAllPlugins()).not.toThrow(); + }); +}); diff --git a/lib/__tests__/plugin-slot.test.tsx b/lib/__tests__/plugin-slot.test.tsx new file mode 100644 index 00000000..ae7b3319 --- /dev/null +++ b/lib/__tests__/plugin-slot.test.tsx @@ -0,0 +1,102 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import React from 'react'; +import { render } from '@testing-library/react'; +import type { SlotRegistration } from '@/lib/plugin-types'; + +// Mock the plugin store +const mockSlots: Record = {}; + +vi.mock('@/stores/plugin-store', () => ({ + usePluginStore: (selector: (s: { slots: typeof mockSlots }) => unknown) => + selector({ slots: mockSlots }), +})); + +// Import after mocks +import { PluginSlot } from '@/components/plugins/plugin-slot'; +import { PluginErrorBoundary } from '@/components/plugins/plugin-error-boundary'; + +beforeEach(() => { + Object.keys(mockSlots).forEach(k => delete mockSlots[k]); +}); + +describe('PluginSlot', () => { + it('renders null when no registrations', () => { + mockSlots['toolbar-actions'] = []; + const { container } = render( + React.createElement(PluginSlot, { name: 'toolbar-actions' }) + ); + expect(container.innerHTML).toBe(''); + }); + + it('renders null when slot has undefined registrations', () => { + // slot entry doesn't exist at all + const { container } = render( + React.createElement(PluginSlot, { name: 'toolbar-actions' }) + ); + expect(container.innerHTML).toBe(''); + }); + + it('renders registered components', () => { + const TestComponent = () => React.createElement('span', null, 'Hello Plugin'); + mockSlots['email-footer'] = [ + { pluginId: 'test', component: TestComponent, order: 100 }, + ]; + const { getByText } = render( + React.createElement(PluginSlot, { name: 'email-footer' }) + ); + expect(getByText('Hello Plugin')).toBeTruthy(); + }); + + it('sets data-plugin-slot attribute', () => { + const TestComponent = () => React.createElement('span', null, 'x'); + mockSlots['sidebar-widget'] = [ + { pluginId: 'sw', component: TestComponent, order: 100 }, + ]; + const { container } = render( + React.createElement(PluginSlot, { name: 'sidebar-widget' }) + ); + expect(container.querySelector('[data-plugin-slot="sidebar-widget"]')).toBeTruthy(); + }); +}); + +describe('PluginErrorBoundary', () => { + it('renders children when no error', () => { + const { getByText } = render( + React.createElement( + PluginErrorBoundary, + { pluginId: 'test' }, + React.createElement('span', null, 'Child') + ) + ); + expect(getByText('Child')).toBeTruthy(); + }); + + it('renders fallback on error', () => { + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const ThrowingComponent = () => { throw new Error('boom'); }; + const { getByText } = render( + React.createElement( + PluginErrorBoundary, + { pluginId: 'err', fallback: React.createElement('span', null, 'Error caught') }, + React.createElement(ThrowingComponent) + ) + ); + expect(getByText('Error caught')).toBeTruthy(); + consoleSpy.mockRestore(); + }); + + it('renders null on error when no fallback provided', () => { + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const ThrowingComponent = () => { throw new Error('boom'); }; + const { container } = render( + React.createElement( + PluginErrorBoundary, + { pluginId: 'err2' }, + React.createElement(ThrowingComponent) + ) + ); + // ErrorBoundary renders null fallback + expect(container.innerHTML).toBe(''); + consoleSpy.mockRestore(); + }); +}); diff --git a/lib/__tests__/plugin-storage.test.ts b/lib/__tests__/plugin-storage.test.ts new file mode 100644 index 00000000..7cc62602 --- /dev/null +++ b/lib/__tests__/plugin-storage.test.ts @@ -0,0 +1,81 @@ +import { describe, it, expect } from 'vitest'; +import 'fake-indexeddb/auto'; +import { pluginStorage } from '../plugin-storage'; + +// Use unique keys per test to avoid shared state (avoiding deleteDatabase which +// blocks on open connections that the module never closes). + +describe('pluginStorage', () => { + describe('plugin code', () => { + it('saves and retrieves code', async () => { + await pluginStorage.saveCode('code-save-1', 'console.log("hello")'); + const code = await pluginStorage.getCode('code-save-1'); + expect(code).toBe('console.log("hello")'); + }); + + it('returns null for missing plugin', async () => { + const code = await pluginStorage.getCode('code-missing-xyz'); + expect(code).toBeNull(); + }); + + it('overwrites existing code', async () => { + await pluginStorage.saveCode('code-overwrite-1', 'v1'); + await pluginStorage.saveCode('code-overwrite-1', 'v2'); + const code = await pluginStorage.getCode('code-overwrite-1'); + expect(code).toBe('v2'); + }); + + it('deletes code', async () => { + await pluginStorage.saveCode('code-del-1', 'code'); + await pluginStorage.deleteCode('code-del-1'); + const code = await pluginStorage.getCode('code-del-1'); + expect(code).toBeNull(); + }); + + it('stores multiple plugins independently', async () => { + await pluginStorage.saveCode('code-multi-a', 'code-a'); + await pluginStorage.saveCode('code-multi-b', 'code-b'); + expect(await pluginStorage.getCode('code-multi-a')).toBe('code-a'); + expect(await pluginStorage.getCode('code-multi-b')).toBe('code-b'); + }); + }); + + describe('theme CSS', () => { + it('saves and retrieves CSS', async () => { + const css = ':root { --color-primary: blue; }'; + await pluginStorage.saveThemeCSS('css-save-1', css); + const result = await pluginStorage.getThemeCSS('css-save-1'); + expect(result).toBe(css); + }); + + it('returns null for missing theme', async () => { + const result = await pluginStorage.getThemeCSS('css-missing-xyz'); + expect(result).toBeNull(); + }); + + it('deletes CSS', async () => { + await pluginStorage.saveThemeCSS('css-del-1', 'css'); + await pluginStorage.deleteThemeCSS('css-del-1'); + expect(await pluginStorage.getThemeCSS('css-del-1')).toBeNull(); + }); + }); + + describe('previews', () => { + it('saves and retrieves preview data URI', async () => { + const dataUri = 'data:image/png;base64,iVBORw0KGgo='; + await pluginStorage.savePreview('prev-save-1', dataUri); + const result = await pluginStorage.getPreview('prev-save-1'); + expect(result).toBe(dataUri); + }); + + it('returns null for missing preview', async () => { + expect(await pluginStorage.getPreview('prev-missing-xyz')).toBeNull(); + }); + + it('deletes preview', async () => { + await pluginStorage.savePreview('prev-del-1', 'data:...'); + await pluginStorage.deletePreview('prev-del-1'); + expect(await pluginStorage.getPreview('prev-del-1')).toBeNull(); + }); + }); +}); diff --git a/lib/__tests__/plugin-store.test.ts b/lib/__tests__/plugin-store.test.ts new file mode 100644 index 00000000..dbbbc378 --- /dev/null +++ b/lib/__tests__/plugin-store.test.ts @@ -0,0 +1,156 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import type { SlotRegistration, InstalledPlugin } from '@/lib/plugin-types'; + +// We test the raw store by directly invoking Zustand +// Mock the external dependencies the store imports +vi.mock('@/lib/plugin-storage', () => ({ + pluginStorage: { + saveCode: vi.fn().mockResolvedValue(undefined), + getCode: vi.fn().mockResolvedValue(null), + deleteCode: vi.fn().mockResolvedValue(undefined), + saveThemeCSS: vi.fn().mockResolvedValue(undefined), + getThemeCSS: vi.fn().mockResolvedValue(null), + deleteThemeCSS: vi.fn().mockResolvedValue(undefined), + savePreview: vi.fn().mockResolvedValue(undefined), + getPreview: vi.fn().mockResolvedValue(null), + deletePreview: vi.fn().mockResolvedValue(undefined), + }, +})); + +vi.mock('@/lib/plugin-validator', () => ({ + extractPlugin: vi.fn(), +})); + +vi.mock('@/lib/plugin-loader', () => ({ + loadPlugin: vi.fn().mockResolvedValue(undefined), + deactivatePlugin: vi.fn(), + setPluginStoreAccessor: vi.fn(), + setupAutoDisable: vi.fn(), +})); + +vi.mock('@/lib/plugin-api', () => ({ + setSlotRegistrationBridge: vi.fn(), +})); + +vi.mock('@/lib/plugin-hooks', () => ({ + removeAllPluginHooks: vi.fn(), +})); + +// Import after mocks +import { usePluginStore } from '@/stores/plugin-store'; + +function resetStore() { + usePluginStore.setState({ + plugins: [], + slots: { + 'toolbar-actions': [], + 'email-banner': [], + 'email-footer': [], + 'composer-toolbar': [], + 'sidebar-widget': [], + 'settings-section': [], + 'context-menu-email': [], + 'navigation-rail-bottom': [], + }, + initialized: false, + }); +} + +function mockPlugin(overrides: Partial = {}): InstalledPlugin { + return { + id: 'test-plugin', + name: 'Test', + version: '1.0.0', + author: 'Test', + description: '', + type: 'hook', + entrypoint: 'index.js', + permissions: [], + enabled: false, + status: 'installed', + settings: {}, + ...overrides, + }; +} + +beforeEach(() => { + resetStore(); + vi.clearAllMocks(); +}); + +describe('usePluginStore', () => { + describe('registerSlot / dispose', () => { + it('adds registration to slot and removes on dispose', () => { + const { registerSlot } = usePluginStore.getState(); + const reg: SlotRegistration = { + pluginId: 'p1', + component: () => null, + order: 100, + }; + const disposable = registerSlot('toolbar-actions', reg); + expect(usePluginStore.getState().slots['toolbar-actions']).toHaveLength(1); + disposable.dispose(); + expect(usePluginStore.getState().slots['toolbar-actions']).toHaveLength(0); + }); + + it('sorts registrations by order', () => { + const { registerSlot } = usePluginStore.getState(); + registerSlot('email-banner', { pluginId: 'p1', component: () => null, order: 200 }); + registerSlot('email-banner', { pluginId: 'p2', component: () => null, order: 50 }); + registerSlot('email-banner', { pluginId: 'p3', component: () => null, order: 100 }); + + const regs = usePluginStore.getState().slots['email-banner']; + expect(regs.map(r => r.pluginId)).toEqual(['p2', 'p3', 'p1']); + }); + }); + + describe('setPluginStatus', () => { + it('updates status for existing plugin', () => { + usePluginStore.setState({ plugins: [mockPlugin()] }); + usePluginStore.getState().setPluginStatus('test-plugin', 'running'); + expect(usePluginStore.getState().plugins[0].status).toBe('running'); + }); + + it('sets error message', () => { + usePluginStore.setState({ plugins: [mockPlugin()] }); + usePluginStore.getState().setPluginStatus('test-plugin', 'error', 'something broke'); + const p = usePluginStore.getState().plugins[0]; + expect(p.status).toBe('error'); + expect(p.error).toBe('something broke'); + }); + }); + + describe('updatePluginSettings', () => { + it('merges settings', () => { + usePluginStore.setState({ plugins: [mockPlugin({ settings: { a: 1 } })] }); + usePluginStore.getState().updatePluginSettings('test-plugin', { b: 2 }); + expect(usePluginStore.getState().plugins[0].settings).toEqual({ a: 1, b: 2 }); + }); + }); + + describe('disablePlugin', () => { + it('sets enabled false and status disabled', () => { + usePluginStore.setState({ + plugins: [mockPlugin({ enabled: true, status: 'running' })], + }); + usePluginStore.getState().disablePlugin('test-plugin'); + const p = usePluginStore.getState().plugins[0]; + expect(p.enabled).toBe(false); + expect(p.status).toBe('disabled'); + }); + }); + + describe('uninstallPlugin', () => { + it('removes plugin from list', () => { + usePluginStore.setState({ plugins: [mockPlugin()] }); + usePluginStore.getState().uninstallPlugin('test-plugin'); + expect(usePluginStore.getState().plugins).toHaveLength(0); + }); + + it('no-op for unknown plugin', () => { + usePluginStore.setState({ plugins: [mockPlugin()] }); + usePluginStore.getState().uninstallPlugin('unknown'); + expect(usePluginStore.getState().plugins).toHaveLength(1); + }); + }); +}); diff --git a/lib/__tests__/plugin-types.test.ts b/lib/__tests__/plugin-types.test.ts new file mode 100644 index 00000000..952cd531 --- /dev/null +++ b/lib/__tests__/plugin-types.test.ts @@ -0,0 +1,135 @@ +import { describe, it, expect } from 'vitest'; +import { + ALL_PERMISSIONS, + IMPLICIT_PERMISSIONS, + MAX_PLUGIN_SIZE, + MAX_THEME_SIZE, + ALLOWED_PLUGIN_FILES, + DISALLOWED_CSS_PATTERNS, +} from '../plugin-types'; + +describe('plugin-types constants', () => { + describe('ALL_PERMISSIONS', () => { + it('contains at least 30 permissions', () => { + expect(ALL_PERMISSIONS.length).toBeGreaterThanOrEqual(30); + }); + + it('has no duplicates', () => { + const unique = new Set(ALL_PERMISSIONS); + expect(unique.size).toBe(ALL_PERMISSIONS.length); + }); + + it('all permissions follow domain:action format', () => { + for (const perm of ALL_PERMISSIONS) { + expect(perm).toMatch(/^[a-z]+:[a-z-]+$/); + } + }); + + it('includes core email/calendar/contacts permissions', () => { + expect(ALL_PERMISSIONS).toContain('email:read'); + expect(ALL_PERMISSIONS).toContain('email:write'); + expect(ALL_PERMISSIONS).toContain('email:send'); + expect(ALL_PERMISSIONS).toContain('calendar:read'); + expect(ALL_PERMISSIONS).toContain('calendar:write'); + expect(ALL_PERMISSIONS).toContain('contacts:read'); + expect(ALL_PERMISSIONS).toContain('contacts:write'); + }); + + it('includes UI permissions for all slot types', () => { + expect(ALL_PERMISSIONS).toContain('ui:toolbar'); + expect(ALL_PERMISSIONS).toContain('ui:email-banner'); + expect(ALL_PERMISSIONS).toContain('ui:email-footer'); + expect(ALL_PERMISSIONS).toContain('ui:composer-toolbar'); + expect(ALL_PERMISSIONS).toContain('ui:sidebar-widget'); + expect(ALL_PERMISSIONS).toContain('ui:settings-section'); + expect(ALL_PERMISSIONS).toContain('ui:context-menu'); + expect(ALL_PERMISSIONS).toContain('ui:navigation-rail'); + }); + }); + + describe('IMPLICIT_PERMISSIONS', () => { + it('contains ui:observe and app:lifecycle', () => { + expect(IMPLICIT_PERMISSIONS).toContain('ui:observe'); + expect(IMPLICIT_PERMISSIONS).toContain('app:lifecycle'); + }); + + it('has exactly 2 implicit permissions', () => { + expect(IMPLICIT_PERMISSIONS).toHaveLength(2); + }); + + it('implicit permissions are in ALL_PERMISSIONS', () => { + for (const perm of IMPLICIT_PERMISSIONS) { + expect(ALL_PERMISSIONS).toContain(perm); + } + }); + }); + + describe('size limits', () => { + it('MAX_PLUGIN_SIZE is 5 MB', () => { + expect(MAX_PLUGIN_SIZE).toBe(5 * 1024 * 1024); + }); + + it('MAX_THEME_SIZE is 1 MB', () => { + expect(MAX_THEME_SIZE).toBe(1 * 1024 * 1024); + }); + }); + + describe('ALLOWED_PLUGIN_FILES', () => { + it('allows JavaScript files', () => { + expect(ALLOWED_PLUGIN_FILES.has('.js')).toBe(true); + expect(ALLOWED_PLUGIN_FILES.has('.mjs')).toBe(true); + }); + + it('allows assets', () => { + expect(ALLOWED_PLUGIN_FILES.has('.css')).toBe(true); + expect(ALLOWED_PLUGIN_FILES.has('.json')).toBe(true); + expect(ALLOWED_PLUGIN_FILES.has('.png')).toBe(true); + expect(ALLOWED_PLUGIN_FILES.has('.svg')).toBe(true); + }); + + it('does not allow executable types', () => { + expect(ALLOWED_PLUGIN_FILES.has('.exe')).toBe(false); + expect(ALLOWED_PLUGIN_FILES.has('.sh')).toBe(false); + expect(ALLOWED_PLUGIN_FILES.has('.bat')).toBe(false); + expect(ALLOWED_PLUGIN_FILES.has('.html')).toBe(false); + }); + }); + + describe('DISALLOWED_CSS_PATTERNS', () => { + it('blocks @import', () => { + const match = DISALLOWED_CSS_PATTERNS.some(p => p.test('@import url("evil.css")')); + expect(match).toBe(true); + }); + + it('blocks external URLs', () => { + const match = DISALLOWED_CSS_PATTERNS.some(p => p.test('background: url("https://evil.com/track.png")')); + expect(match).toBe(true); + }); + + it('blocks javascript: in CSS', () => { + const match = DISALLOWED_CSS_PATTERNS.some(p => p.test('background: javascript:alert(1)')); + expect(match).toBe(true); + }); + + it('blocks expression()', () => { + const match = DISALLOWED_CSS_PATTERNS.some(p => p.test('width: expression(document.body.clientWidth)')); + expect(match).toBe(true); + }); + + it('blocks -moz-binding', () => { + const match = DISALLOWED_CSS_PATTERNS.some(p => p.test('-moz-binding: url("evil.xml#xbl")')); + expect(match).toBe(true); + }); + + it('blocks behavior:', () => { + const match = DISALLOWED_CSS_PATTERNS.some(p => p.test('behavior: url(evil.htc)')); + expect(match).toBe(true); + }); + + it('allows safe CSS', () => { + const safeCSS = ':root { --color-primary: #3b82f6; }'; + const match = DISALLOWED_CSS_PATTERNS.some(p => p.test(safeCSS)); + expect(match).toBe(false); + }); + }); +}); diff --git a/lib/__tests__/plugin-validator.test.ts b/lib/__tests__/plugin-validator.test.ts new file mode 100644 index 00000000..bd432829 --- /dev/null +++ b/lib/__tests__/plugin-validator.test.ts @@ -0,0 +1,322 @@ +import { describe, it, expect } from 'vitest'; +import JSZip from 'jszip'; +import { extractTheme, extractPlugin } from '../plugin-validator'; + +function createZipFile(zip: JSZip, name = 'test.zip'): Promise { + return zip.generateAsync({ type: 'blob' }).then(blob => new File([blob], name)); +} + +describe('extractTheme', () => { + it('extracts a valid theme ZIP', async () => { + const zip = new JSZip(); + zip.file('manifest.json', JSON.stringify({ + id: 'my-theme', + name: 'My Theme', + version: '1.0.0', + author: 'Test', + type: 'theme', + variants: ['light', 'dark'], + })); + zip.file('theme.css', ':root { --color-primary: #ff0000; }\n.dark { --color-primary: #00ff00; }'); + + const file = await createZipFile(zip); + const result = await extractTheme(file); + expect(result.valid).toBe(true); + expect(result.manifest).not.toBeNull(); + expect(result.manifest!.id).toBe('my-theme'); + expect(result.css).toContain('--color-primary'); + }); + + it('rejects oversized theme', async () => { + const zip = new JSZip(); + zip.file('manifest.json', JSON.stringify({ + id: 'big-theme', + name: 'Big', + version: '1.0.0', + author: 'Test', + type: 'theme', + variants: ['light'], + })); + // Make a large file > 1MB + zip.file('theme.css', 'x'.repeat(1024 * 1024 + 1)); + + // Manually create oversized File + const oversizedFile = new File([new ArrayBuffer(1024 * 1024 + 1)], 'big.zip'); + const result = await extractTheme(oversizedFile); + expect(result.valid).toBe(false); + expect(result.errors).toContain('Theme ZIP exceeds 1 MB size limit'); + }); + + it('rejects non-ZIP file', async () => { + const file = new File(['not a zip'], 'bad.zip'); + const result = await extractTheme(file); + expect(result.valid).toBe(false); + expect(result.errors).toContain('Invalid ZIP file'); + }); + + it('rejects missing manifest.json', async () => { + const zip = new JSZip(); + zip.file('theme.css', ':root { --color-primary: blue; }'); + const file = await createZipFile(zip); + const result = await extractTheme(file); + expect(result.valid).toBe(false); + expect(result.errors).toContain('Missing manifest.json'); + }); + + it('rejects invalid JSON manifest', async () => { + const zip = new JSZip(); + zip.file('manifest.json', 'not json {{{'); + zip.file('theme.css', ':root { --color-primary: blue; }'); + const file = await createZipFile(zip); + const result = await extractTheme(file); + expect(result.valid).toBe(false); + expect(result.errors).toContain('Invalid manifest.json (not valid JSON)'); + }); + + it('rejects missing theme.css', async () => { + const zip = new JSZip(); + zip.file('manifest.json', JSON.stringify({ + id: 'no-css', + name: 'No CSS', + version: '1.0.0', + author: 'Test', + type: 'theme', + variants: ['light'], + })); + const file = await createZipFile(zip); + const result = await extractTheme(file); + expect(result.valid).toBe(false); + expect(result.errors).toContain('Missing theme.css'); + }); + + it('rejects wrong type in manifest', async () => { + const zip = new JSZip(); + zip.file('manifest.json', JSON.stringify({ + id: 'wrong-type', + name: 'Wrong', + version: '1.0.0', + author: 'Test', + type: 'plugin', // wrong + variants: ['light'], + })); + zip.file('theme.css', ':root { --color-primary: blue; }'); + const file = await createZipFile(zip); + const result = await extractTheme(file); + expect(result.valid).toBe(false); + expect(result.errors.some(e => e.includes('Expected type "theme"'))).toBe(true); + }); + + it('rejects missing variants', async () => { + const zip = new JSZip(); + zip.file('manifest.json', JSON.stringify({ + id: 'no-variants', + name: 'No Variants', + version: '1.0.0', + author: 'Test', + type: 'theme', + })); + zip.file('theme.css', ':root { --color-primary: blue; }'); + const file = await createZipFile(zip); + const result = await extractTheme(file); + expect(result.valid).toBe(false); + expect(result.errors.some(e => e.includes('variants'))).toBe(true); + }); + + it('handles ZIP with folder root', async () => { + const zip = new JSZip(); + const folder = zip.folder('my-theme')!; + folder.file('manifest.json', JSON.stringify({ + id: 'nested-theme', + name: 'Nested', + version: '1.0.0', + author: 'Test', + type: 'theme', + variants: ['light', 'dark'], + })); + folder.file('theme.css', ':root { --color-primary: #aaa; }\n.dark { --color-primary: #bbb; }'); + const file = await createZipFile(zip); + const result = await extractTheme(file); + expect(result.valid).toBe(true); + expect(result.manifest!.id).toBe('nested-theme'); + }); +}); + +describe('extractPlugin', () => { + it('extracts a valid plugin ZIP', async () => { + const zip = new JSZip(); + zip.file('manifest.json', JSON.stringify({ + id: 'my-plugin', + name: 'My Plugin', + version: '1.0.0', + author: 'Test', + type: 'ui-extension', + entrypoint: 'index.js', + permissions: ['email:read'], + })); + zip.file('index.js', 'export function activate(api) { console.log("hi"); }'); + + const file = await createZipFile(zip); + const result = await extractPlugin(file); + expect(result.valid).toBe(true); + expect(result.manifest!.id).toBe('my-plugin'); + expect(result.code).toContain('activate'); + }); + + it('rejects oversized plugin', async () => { + const oversizedFile = new File([new ArrayBuffer(5 * 1024 * 1024 + 1)], 'big.zip'); + const result = await extractPlugin(oversizedFile); + expect(result.valid).toBe(false); + expect(result.errors).toContain('Plugin ZIP exceeds 5 MB size limit'); + }); + + it('rejects non-ZIP file', async () => { + const file = new File(['not a zip'], 'bad.zip'); + const result = await extractPlugin(file); + expect(result.valid).toBe(false); + expect(result.errors).toContain('Invalid ZIP file'); + }); + + it('rejects missing manifest', async () => { + const zip = new JSZip(); + zip.file('index.js', 'export function activate() {}'); + const file = await createZipFile(zip); + const result = await extractPlugin(file); + expect(result.valid).toBe(false); + expect(result.errors).toContain('Missing manifest.json'); + }); + + it('rejects disallowed file types', async () => { + const zip = new JSZip(); + zip.file('manifest.json', JSON.stringify({ + id: 'bad-files', + name: 'Bad', + version: '1.0.0', + author: 'Test', + type: 'hook', + entrypoint: 'index.js', + permissions: [], + })); + zip.file('index.js', 'export function activate() {}'); + zip.file('hack.exe', 'binary'); + + const file = await createZipFile(zip); + const result = await extractPlugin(file); + expect(result.valid).toBe(false); + expect(result.errors.some(e => e.includes('.exe'))).toBe(true); + }); + + it('rejects unknown permissions', async () => { + const zip = new JSZip(); + zip.file('manifest.json', JSON.stringify({ + id: 'bad-perms', + name: 'Bad perms', + version: '1.0.0', + author: 'Test', + type: 'hook', + entrypoint: 'index.js', + permissions: ['email:read', 'nuclear:launch'], + })); + zip.file('index.js', 'export function activate() {}'); + + const file = await createZipFile(zip); + const result = await extractPlugin(file); + expect(result.valid).toBe(false); + expect(result.errors.some(e => e.includes('nuclear:launch'))).toBe(true); + }); + + it('warns about eval() in code', async () => { + const zip = new JSZip(); + zip.file('manifest.json', JSON.stringify({ + id: 'eval-plugin', + name: 'Eval', + version: '1.0.0', + author: 'Test', + type: 'hook', + entrypoint: 'index.js', + permissions: [], + })); + zip.file('index.js', 'export function activate() { eval("alert(1)"); }'); + + const file = await createZipFile(zip); + const result = await extractPlugin(file); + expect(result.valid).toBe(true); + expect(result.warnings.some(w => w.includes('eval()'))).toBe(true); + }); + + it('warns about document.cookie in code', async () => { + const zip = new JSZip(); + zip.file('manifest.json', JSON.stringify({ + id: 'cookie-plugin', + name: 'Cookie', + version: '1.0.0', + author: 'Test', + type: 'hook', + entrypoint: 'index.js', + permissions: [], + })); + zip.file('index.js', 'export function activate() { const c = document.cookie; }'); + + const file = await createZipFile(zip); + const result = await extractPlugin(file); + expect(result.valid).toBe(true); + expect(result.warnings.some(w => w.includes('document.cookie'))).toBe(true); + }); + + it('rejects invalid plugin type', async () => { + const zip = new JSZip(); + zip.file('manifest.json', JSON.stringify({ + id: 'bad-type', + name: 'Bad Type', + version: '1.0.0', + author: 'Test', + type: 'theme', // wrong type for plugin + entrypoint: 'index.js', + permissions: [], + })); + zip.file('index.js', 'export function activate() {}'); + + const file = await createZipFile(zip); + const result = await extractPlugin(file); + expect(result.valid).toBe(false); + expect(result.errors.some(e => e.includes('Invalid type'))).toBe(true); + }); + + it('rejects missing entrypoint', async () => { + const zip = new JSZip(); + zip.file('manifest.json', JSON.stringify({ + id: 'no-entry', + name: 'No Entry', + version: '1.0.0', + author: 'Test', + type: 'hook', + entrypoint: 'main.js', + permissions: [], + })); + zip.file('index.js', 'export function activate() {}'); + // entrypoint 'main.js' doesn't exist + + const file = await createZipFile(zip); + const result = await extractPlugin(file); + expect(result.valid).toBe(false); + expect(result.errors.some(e => e.includes('Missing entrypoint'))).toBe(true); + }); + + it('rejects invalid manifest ID format', async () => { + const zip = new JSZip(); + zip.file('manifest.json', JSON.stringify({ + id: 'Bad_ID!', + name: 'Bad ID', + version: '1.0.0', + author: 'Test', + type: 'hook', + entrypoint: 'index.js', + permissions: [], + })); + zip.file('index.js', 'export function activate() {}'); + + const file = await createZipFile(zip); + const result = await extractPlugin(file); + expect(result.valid).toBe(false); + expect(result.errors.some(e => e.includes('ID must be lowercase'))).toBe(true); + }); +}); diff --git a/lib/__tests__/theme-loader.test.ts b/lib/__tests__/theme-loader.test.ts new file mode 100644 index 00000000..bd3aa4a2 --- /dev/null +++ b/lib/__tests__/theme-loader.test.ts @@ -0,0 +1,175 @@ +import { describe, it, expect, afterEach } from 'vitest'; +import { + sanitizeThemeCSS, + validateThemeSelectors, + injectThemeCSS, + removeThemeCSS, + validateThemeCSSSafety, +} from '../theme-loader'; + +describe('theme-loader', () => { + describe('sanitizeThemeCSS', () => { + it('passes through safe CSS unchanged', () => { + const css = ':root { --color-primary: #3b82f6; }'; + const { css: cleaned, warnings } = sanitizeThemeCSS(css); + expect(cleaned).toBe(css); + expect(warnings).toHaveLength(0); + }); + + it('strips @import directives', () => { + const css = '@import url("evil.css");\n:root { --color-primary: red; }'; + const { css: cleaned, warnings } = sanitizeThemeCSS(css); + expect(cleaned).not.toContain('@import'); + expect(warnings.length).toBeGreaterThan(0); + }); + + it('strips external url() references', () => { + const css = ':root { background: url("https://evil.com/track.png"); }'; + const { css: cleaned, warnings } = sanitizeThemeCSS(css); + expect(cleaned).not.toContain('https://evil.com'); + expect(warnings.length).toBeGreaterThan(0); + }); + + it('strips javascript: in CSS', () => { + const css = ':root { background: javascript:alert(1); }'; + const { css: cleaned, warnings } = sanitizeThemeCSS(css); + expect(cleaned).not.toContain('javascript:'); + expect(warnings.length).toBeGreaterThan(0); + }); + + it('strips expression()', () => { + const css = ':root { width: expression(document.body.clientWidth); }'; + const { css: cleaned, warnings } = sanitizeThemeCSS(css); + expect(cleaned).not.toContain('expression('); + expect(warnings.length).toBeGreaterThan(0); + }); + + it('strips -moz-binding', () => { + const css = ':root { -moz-binding: url("evil.xml#xbl"); }'; + const { css: cleaned, warnings } = sanitizeThemeCSS(css); + expect(cleaned).not.toContain('-moz-binding'); + expect(warnings.length).toBeGreaterThan(0); + }); + + it('strips behavior:', () => { + const css = ':root { behavior: url(evil.htc); }'; + const { css: cleaned, warnings } = sanitizeThemeCSS(css); + expect(cleaned).not.toContain('behavior'); + expect(warnings.length).toBeGreaterThan(0); + }); + + it('strips multiple dangerous patterns at once', () => { + const css = '@import url("a.css"); :root { -moz-binding: url("b.xml"); background: expression(1); }'; + const { css: cleaned, warnings } = sanitizeThemeCSS(css); + expect(cleaned).not.toContain('@import'); + expect(cleaned).not.toContain('-moz-binding'); + expect(cleaned).not.toContain('expression('); + expect(warnings.length).toBeGreaterThanOrEqual(3); + }); + }); + + describe('validateThemeSelectors', () => { + it('accepts :root selector', () => { + const warnings = validateThemeSelectors(':root { --color-primary: red; }'); + expect(warnings).toHaveLength(0); + }); + + it('accepts .dark selector', () => { + const warnings = validateThemeSelectors('.dark { --color-primary: blue; }'); + expect(warnings).toHaveLength(0); + }); + + it('accepts @media queries', () => { + const css = '@media (prefers-color-scheme: dark) { :root { --color-bg: #000; } }'; + const warnings = validateThemeSelectors(css); + expect(warnings).toHaveLength(0); + }); + + it('accepts @font-face', () => { + const css = '@font-face { font-family: "Test"; src: local("Test"); }'; + const warnings = validateThemeSelectors(css); + expect(warnings).toHaveLength(0); + }); + + it('warns about body selector', () => { + const css = 'body { background: red; }'; + const warnings = validateThemeSelectors(css); + expect(warnings.length).toBeGreaterThan(0); + expect(warnings[0]).toContain('body'); + }); + + it('warns about element selectors', () => { + const css = 'button { color: red; }'; + const warnings = validateThemeSelectors(css); + expect(warnings.length).toBeGreaterThan(0); + }); + + it('warns about class selectors other than .dark', () => { + const css = '.my-class { color: red; }'; + const warnings = validateThemeSelectors(css); + expect(warnings.length).toBeGreaterThan(0); + }); + }); + + describe('injectThemeCSS / removeThemeCSS', () => { + afterEach(() => { + removeThemeCSS(); + }); + + it('injects a style element into head', () => { + injectThemeCSS(':root { --color-primary: red; }'); + const styleEl = document.getElementById('active-theme'); + expect(styleEl).not.toBeNull(); + expect(styleEl?.tagName).toBe('STYLE'); + expect(styleEl?.textContent).toBe(':root { --color-primary: red; }'); + }); + + it('updates existing style element on subsequent call', () => { + injectThemeCSS(':root { --color-primary: red; }'); + injectThemeCSS(':root { --color-primary: blue; }'); + const styleEls = document.querySelectorAll('#active-theme'); + expect(styleEls).toHaveLength(1); + expect(styleEls[0].textContent).toBe(':root { --color-primary: blue; }'); + }); + + it('removeThemeCSS removes the style element', () => { + injectThemeCSS(':root { --color-primary: red; }'); + removeThemeCSS(); + const styleEl = document.getElementById('active-theme'); + expect(styleEl).toBeNull(); + }); + + it('removeThemeCSS is safe when no theme is injected', () => { + expect(() => removeThemeCSS()).not.toThrow(); + }); + }); + + describe('validateThemeCSSSafety', () => { + it('accepts valid theme CSS', () => { + const css = ':root { --color-primary: #3b82f6; --color-background: #fff; }'; + const { valid, errors } = validateThemeCSSSafety(css); + expect(valid).toBe(true); + expect(errors).toHaveLength(0); + }); + + it('rejects empty CSS', () => { + const { valid, errors } = validateThemeCSSSafety(' '); + expect(valid).toBe(false); + expect(errors).toContain('Theme CSS is empty'); + }); + + it('rejects CSS without color variables', () => { + const css = ':root { font-size: 16px; }'; + const { valid, errors } = validateThemeCSSSafety(css); + expect(valid).toBe(false); + expect(errors.some(e => e.includes('--color-'))).toBe(true); + }); + + it('flags dangerous patterns', () => { + const css = ':root { --color-primary: red; } @import url("evil.css");'; + const { valid, errors } = validateThemeCSSSafety(css); + expect(valid).toBe(false); + expect(errors.some(e => e.includes('disallowed'))).toBe(true); + }); + }); +}); diff --git a/lib/admin/audit.ts b/lib/admin/audit.ts new file mode 100644 index 00000000..9b3a6ded --- /dev/null +++ b/lib/admin/audit.ts @@ -0,0 +1,91 @@ +import { appendFile, stat, rename, mkdir } from 'node:fs/promises'; +import { existsSync } from 'node:fs'; +import path from 'node:path'; +import { logger } from '@/lib/logger'; +import type { AuditEntry } from './types'; + +const MAX_LOG_SIZE = 10 * 1024 * 1024; // 10 MB +const MAX_ROTATIONS = 3; + +function getAdminDir(): string { + return process.env.ADMIN_DATA_DIR || path.join(process.cwd(), 'data', 'admin'); +} + +function getAuditLogPath(): string { + return path.join(getAdminDir(), 'audit.log'); +} + +/** + * Append an audit entry to the admin audit log. + */ +export async function auditLog(action: string, detail: Record, ip: string): Promise { + const dir = getAdminDir(); + if (!existsSync(dir)) { + await mkdir(dir, { recursive: true }); + } + + const entry: AuditEntry = { + ts: new Date().toISOString(), + action, + detail, + ip, + }; + + const logPath = getAuditLogPath(); + try { + await appendFile(logPath, JSON.stringify(entry) + '\n', 'utf-8'); + await rotateIfNeeded(logPath); + } catch (error) { + logger.error('Failed to write audit log', { error: error instanceof Error ? error.message : 'Unknown error' }); + } +} + +async function rotateIfNeeded(logPath: string): Promise { + try { + const stats = await stat(logPath); + if (stats.size < MAX_LOG_SIZE) return; + + // Rotate: audit.log.3 → deleted, audit.log.2 → .3, audit.log.1 → .2, audit.log → .1 + for (let i = MAX_ROTATIONS; i >= 1; i--) { + const from = i === 1 ? logPath : `${logPath}.${i - 1}`; + const to = `${logPath}.${i}`; + if (existsSync(from)) { + try { await rename(from, to); } catch { /* target may exist on overwrite */ } + } + } + } catch { + // stat failed, probably file doesn't exist yet + } +} + +/** + * Read audit log entries, newest first. Supports pagination. + */ +export async function readAuditLog(page: number = 1, limit: number = 50, actionFilter?: string): Promise<{ entries: AuditEntry[]; total: number }> { + const logPath = getAuditLogPath(); + try { + const { readFile } = await import('node:fs/promises'); + const content = await readFile(logPath, 'utf-8'); + const lines = content.trim().split('\n').filter(Boolean); + + let entries: AuditEntry[] = lines.map(line => { + try { return JSON.parse(line); } catch { return null; } + }).filter((e): e is AuditEntry => e !== null); + + if (actionFilter) { + entries = entries.filter(e => e.action === actionFilter); + } + + const total = entries.length; + // Return newest first + entries.reverse(); + const start = (page - 1) * limit; + return { entries: entries.slice(start, start + limit), total }; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return { entries: [], total: 0 }; + } + logger.warn('Failed to read audit log', { error: error instanceof Error ? error.message : 'Unknown error' }); + return { entries: [], total: 0 }; + } +} diff --git a/lib/admin/config-manager.ts b/lib/admin/config-manager.ts new file mode 100644 index 00000000..c97a34f8 --- /dev/null +++ b/lib/admin/config-manager.ts @@ -0,0 +1,159 @@ +import { readFile, writeFile, mkdir, rename } from 'node:fs/promises'; +import { existsSync } from 'node:fs'; +import path from 'node:path'; +import { logger } from '@/lib/logger'; +import { CONFIG_ENV_MAP, DEFAULT_POLICY, type SettingsPolicy } from './types'; + +function getAdminDir(): string { + return process.env.ADMIN_DATA_DIR || path.join(process.cwd(), 'data', 'admin'); +} + +function parseEnvValue(value: string, type: string): unknown { + switch (type) { + case 'boolean': + return value === 'true'; + case 'string': + case 'url': + case 'enum': + return value; + default: + return value; + } +} + +class ConfigManager { + private adminConfig: Record = {}; + private policyCache: SettingsPolicy = { ...DEFAULT_POLICY }; + private loaded = false; + + /** Load admin config and policy from disk. Called once at startup and on reload. */ + async load(): Promise { + this.adminConfig = await this.readJsonFile('config.json') || {}; + const policy = await this.readJsonFile('policy.json'); + this.policyCache = policy ? { ...DEFAULT_POLICY, ...policy } : { ...DEFAULT_POLICY }; + this.loaded = true; + logger.debug('ConfigManager loaded', { configKeys: Object.keys(this.adminConfig).length }); + } + + /** Ensure config is loaded (no-op if already loaded). */ + async ensureLoaded(): Promise { + if (!this.loaded) await this.load(); + } + + /** + * Get a config value. Priority: admin override > env var > default. + */ + get(key: string, defaultValue?: T): T { + // Admin override (highest priority) + if (key in this.adminConfig) { + return this.adminConfig[key] as T; + } + + // Environment variable + const mapping = CONFIG_ENV_MAP[key]; + if (mapping) { + const envVal = process.env[mapping.envVar]; + if (envVal !== undefined) { + return parseEnvValue(envVal, mapping.type) as T; + } + if (defaultValue !== undefined) return defaultValue; + return mapping.defaultValue as T; + } + + return defaultValue as T; + } + + /** + * Get all config values as a flat object (merged from all layers). + */ + getAll(): Record { + const result: Record = {}; + for (const [key, mapping] of Object.entries(CONFIG_ENV_MAP)) { + result[key] = this.get(key, mapping.defaultValue); + } + return result; + } + + /** + * Get all config values with source information (for admin UI). + */ + getAllWithSources(): Record { + const result: Record = {}; + for (const [key, mapping] of Object.entries(CONFIG_ENV_MAP)) { + if (key in this.adminConfig) { + result[key] = { value: this.adminConfig[key], source: 'admin' }; + } else { + const envVal = process.env[mapping.envVar]; + if (envVal !== undefined) { + result[key] = { value: parseEnvValue(envVal, mapping.type), source: 'env' }; + } else { + result[key] = { value: mapping.defaultValue, source: 'default' }; + } + } + } + return result; + } + + /** + * Update admin config overrides. Writes to disk. + */ + async setAdminConfig(updates: Record): Promise { + Object.assign(this.adminConfig, updates); + await this.writeJsonFile('config.json', this.adminConfig); + } + + /** + * Remove an admin override, reverting to env/default. + */ + async removeAdminOverride(key: string): Promise { + delete this.adminConfig[key]; + await this.writeJsonFile('config.json', this.adminConfig); + } + + /** + * Get the current settings policy. + */ + getPolicy(): SettingsPolicy { + return this.policyCache; + } + + /** + * Update the settings policy. Writes to disk. + */ + async setPolicy(policy: SettingsPolicy): Promise { + this.policyCache = { ...DEFAULT_POLICY, ...policy }; + await this.writeJsonFile('policy.json', this.policyCache as unknown as Record); + } + + /** + * Reload config from disk (for manual file edits or multi-instance). + */ + async reload(): Promise { + await this.load(); + } + + private async readJsonFile(filename: string): Promise | null> { + const filePath = path.join(getAdminDir(), filename); + try { + const raw = await readFile(filePath, 'utf-8'); + return JSON.parse(raw); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null; + logger.warn(`Failed to read ${filename}`, { error: error instanceof Error ? error.message : 'Unknown error' }); + return null; + } + } + + private async writeJsonFile(filename: string, data: Record): Promise { + const dir = getAdminDir(); + if (!existsSync(dir)) { + await mkdir(dir, { recursive: true }); + } + const targetPath = path.join(dir, filename); + const tmpPath = targetPath + '.tmp'; + await writeFile(tmpPath, JSON.stringify(data, null, 2), 'utf-8'); + await rename(tmpPath, targetPath); + } +} + +export const configManager = new ConfigManager(); diff --git a/lib/admin/password.ts b/lib/admin/password.ts new file mode 100644 index 00000000..64fe191d --- /dev/null +++ b/lib/admin/password.ts @@ -0,0 +1,206 @@ +import { scrypt, randomBytes, timingSafeEqual } from 'node:crypto'; +import { readFile, writeFile, mkdir, rename } from 'node:fs/promises'; +import { existsSync } from 'node:fs'; +import path from 'node:path'; +import { logger } from '@/lib/logger'; +import type { AdminData } from './types'; + +const SCRYPT_KEYLEN = 64; +const SCRYPT_COST = 16384; // 2^14 +const SCRYPT_BLOCK_SIZE = 8; +const SCRYPT_PARALLELIZATION = 1; +const SALT_LENGTH = 32; + +function getAdminDir(): string { + return process.env.ADMIN_DATA_DIR || path.join(process.cwd(), 'data', 'admin'); +} + +function getAdminJsonPath(): string { + return path.join(getAdminDir(), 'admin.json'); +} + +function hashPassword(password: string): Promise { + return new Promise((resolve, reject) => { + const salt = randomBytes(SALT_LENGTH); + scrypt(password, salt, SCRYPT_KEYLEN, { N: SCRYPT_COST, r: SCRYPT_BLOCK_SIZE, p: SCRYPT_PARALLELIZATION }, (err, derivedKey) => { + if (err) return reject(err); + // Format: $scrypt$N=16384,r=8,p=1$$ + const params = `N=${SCRYPT_COST},r=${SCRYPT_BLOCK_SIZE},p=${SCRYPT_PARALLELIZATION}`; + resolve(`$scrypt$${params}$${salt.toString('base64')}$${derivedKey.toString('base64')}`); + }); + }); +} + +function verifyPassword(password: string, stored: string): Promise { + return new Promise((resolve, reject) => { + // Support both scrypt format and bcrypt-prefixed values + if (stored.startsWith('$scrypt$')) { + const parts = stored.split('$'); + // $scrypt$N=...,r=...,p=...$salt$hash + if (parts.length !== 5) return resolve(false); + const paramStr = parts[2]; + const salt = Buffer.from(parts[3], 'base64'); + const storedHash = Buffer.from(parts[4], 'base64'); + + const params: Record = {}; + for (const p of paramStr.split(',')) { + const [k, v] = p.split('='); + params[k] = parseInt(v, 10); + } + + scrypt(password, salt, storedHash.length, { N: params.N, r: params.r, p: params.p }, (err, derivedKey) => { + if (err) return reject(err); + resolve(timingSafeEqual(derivedKey, storedHash)); + }); + } else { + // Unknown format + resolve(false); + } + }); +} + +function isHashed(value: string): boolean { + return value.startsWith('$scrypt$') || value.startsWith('$2a$') || value.startsWith('$2b$'); +} + +async function readAdminData(): Promise { + const filePath = getAdminJsonPath(); + try { + const raw = await readFile(filePath, 'utf-8'); + return JSON.parse(raw) as AdminData; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null; + logger.warn('Failed to read admin.json', { error: error instanceof Error ? error.message : 'Unknown error' }); + return null; + } +} + +async function writeAdminData(data: AdminData): Promise { + const dir = getAdminDir(); + if (!existsSync(dir)) { + await mkdir(dir, { recursive: true }); + } + const targetPath = getAdminJsonPath(); + const tmpPath = targetPath + '.tmp'; + await writeFile(tmpPath, JSON.stringify(data, null, 2), 'utf-8'); + await rename(tmpPath, targetPath); +} + +let cachedAdminData: AdminData | null = null; +let initialized = false; + +/** + * Initialize admin password on startup. + * If ADMIN_PASSWORD is cleartext, hash it and write to admin.json. + * Returns true if admin is enabled. + */ +export async function initAdminPassword(): Promise { + if (initialized) return cachedAdminData !== null; + + // Check persistent file first + const existing = await readAdminData(); + if (existing) { + cachedAdminData = existing; + initialized = true; + logger.info('Admin dashboard enabled (password loaded from admin.json)'); + return true; + } + + // Check env var + const envPassword = process.env.ADMIN_PASSWORD; + if (!envPassword) { + initialized = true; + logger.info('Admin dashboard disabled (no ADMIN_PASSWORD set)'); + return false; + } + + if (isHashed(envPassword)) { + // Already hashed in env — save to file + const data: AdminData = { + passwordHash: envPassword, + createdAt: new Date().toISOString(), + lastLogin: null, + passwordChangedAt: new Date().toISOString(), + }; + await writeAdminData(data); + cachedAdminData = data; + initialized = true; + logger.info('Admin password hash saved to admin.json from environment variable'); + return true; + } + + // Cleartext — hash it + const hash = await hashPassword(envPassword); + const data: AdminData = { + passwordHash: hash, + createdAt: new Date().toISOString(), + lastLogin: null, + passwordChangedAt: new Date().toISOString(), + }; + await writeAdminData(data); + cachedAdminData = data; + initialized = true; + logger.warn('Admin password hashed and saved to admin.json. You may now remove ADMIN_PASSWORD from .env'); + return true; +} + +/** + * Verify a password against the stored admin hash. + */ +export async function verifyAdminPassword(password: string): Promise { + if (!cachedAdminData) { + cachedAdminData = await readAdminData(); + } + if (!cachedAdminData) return false; + return verifyPassword(password, cachedAdminData.passwordHash); +} + +/** + * Change the admin password. Returns true on success. + */ +export async function changeAdminPassword(currentPassword: string, newPassword: string): Promise { + const valid = await verifyAdminPassword(currentPassword); + if (!valid) return false; + + const hash = await hashPassword(newPassword); + if (!cachedAdminData) return false; + + cachedAdminData = { + ...cachedAdminData, + passwordHash: hash, + passwordChangedAt: new Date().toISOString(), + }; + await writeAdminData(cachedAdminData); + return true; +} + +/** + * Update the last login timestamp. + */ +export async function updateLastLogin(): Promise { + if (!cachedAdminData) return; + cachedAdminData = { + ...cachedAdminData, + lastLogin: new Date().toISOString(), + }; + await writeAdminData(cachedAdminData); +} + +/** + * Check if admin dashboard is enabled (has a password configured). + */ +export function isAdminEnabled(): boolean { + return cachedAdminData !== null; +} + +/** + * Get admin metadata (without the hash). + */ +export function getAdminMeta(): { createdAt: string; lastLogin: string | null; passwordChangedAt: string } | null { + if (!cachedAdminData) return null; + return { + createdAt: cachedAdminData.createdAt, + lastLogin: cachedAdminData.lastLogin, + passwordChangedAt: cachedAdminData.passwordChangedAt, + }; +} diff --git a/lib/admin/rate-limit.ts b/lib/admin/rate-limit.ts new file mode 100644 index 00000000..49fea10b --- /dev/null +++ b/lib/admin/rate-limit.ts @@ -0,0 +1,45 @@ +/** + * In-memory rate limiter for admin login. + * Max 5 attempts per IP per 15 minutes. + */ + +const MAX_ATTEMPTS = 5; +const WINDOW_MS = 15 * 60 * 1000; // 15 minutes + +interface RateLimitEntry { + count: number; + resetAt: number; +} + +const attempts = new Map(); + +// Clean up expired entries periodically +setInterval(() => { + const now = Date.now(); + for (const [key, entry] of attempts) { + if (entry.resetAt <= now) { + attempts.delete(key); + } + } +}, 60_000).unref(); + +/** + * Check if the IP is rate limited. Returns remaining attempts, or 0 if blocked. + */ +export function checkRateLimit(ip: string): { allowed: boolean; remaining: number; retryAfterMs: number } { + const now = Date.now(); + const entry = attempts.get(ip); + + if (!entry || entry.resetAt <= now) { + // New window + attempts.set(ip, { count: 1, resetAt: now + WINDOW_MS }); + return { allowed: true, remaining: MAX_ATTEMPTS - 1, retryAfterMs: 0 }; + } + + if (entry.count >= MAX_ATTEMPTS) { + return { allowed: false, remaining: 0, retryAfterMs: entry.resetAt - now }; + } + + entry.count++; + return { allowed: true, remaining: MAX_ATTEMPTS - entry.count, retryAfterMs: 0 }; +} diff --git a/lib/admin/session.ts b/lib/admin/session.ts new file mode 100644 index 00000000..629c6f79 --- /dev/null +++ b/lib/admin/session.ts @@ -0,0 +1,126 @@ +import { cookies } from 'next/headers'; +import { NextResponse } from 'next/server'; +import { createCipheriv, createDecipheriv, randomBytes, createHash } from 'node:crypto'; +import { ADMIN_SESSION_COOKIE, DEFAULT_ADMIN_SESSION_TTL } from './types'; +import type { AdminSessionPayload } from './types'; + +const ALGORITHM = 'aes-256-gcm'; +const IV_LENGTH = 12; +const TAG_LENGTH = 16; + +function getKey(): Buffer { + const secret = process.env.SESSION_SECRET; + if (!secret) throw new Error('SESSION_SECRET not configured'); + return createHash('sha256').update(secret).digest(); +} + +function getSessionTTL(): number { + const ttl = parseInt(process.env.ADMIN_SESSION_TTL || '', 10); + return isNaN(ttl) || ttl <= 0 ? DEFAULT_ADMIN_SESSION_TTL : ttl; +} + +/** + * Create an encrypted admin session token. + */ +export function createAdminSession(): string { + const key = getKey(); + const iv = randomBytes(IV_LENGTH); + const cipher = createCipheriv(ALGORITHM, key, iv); + + const now = Math.floor(Date.now() / 1000); + const payload: AdminSessionPayload = { + role: 'admin', + iat: now, + exp: now + getSessionTTL(), + }; + + const json = JSON.stringify(payload); + const encrypted = Buffer.concat([cipher.update(json, 'utf8'), cipher.final()]); + const tag = cipher.getAuthTag(); + + return Buffer.concat([iv, tag, encrypted]).toString('base64'); +} + +/** + * Verify and decode an admin session token. Returns null if invalid or expired. + */ +export function verifyAdminSession(token: string): AdminSessionPayload | null { + try { + const key = getKey(); + const data = Buffer.from(token, 'base64'); + if (data.length < IV_LENGTH + TAG_LENGTH) return null; + + const iv = data.subarray(0, IV_LENGTH); + const tag = data.subarray(IV_LENGTH, IV_LENGTH + TAG_LENGTH); + const encrypted = data.subarray(IV_LENGTH + TAG_LENGTH); + + const decipher = createDecipheriv(ALGORITHM, key, iv); + decipher.setAuthTag(tag); + + const decrypted = Buffer.concat([decipher.update(encrypted), decipher.final()]); + const payload = JSON.parse(decrypted.toString('utf8')) as AdminSessionPayload; + + if (payload.role !== 'admin') return null; + + const now = Math.floor(Date.now() / 1000); + if (payload.exp < now) return null; + + return payload; + } catch { + return null; + } +} + +/** + * Validate the admin session from cookies. Returns the payload or a 401 response. + */ +export async function requireAdminAuth(): Promise<{ payload: AdminSessionPayload } | { error: NextResponse }> { + const cookieStore = await cookies(); + const token = cookieStore.get(ADMIN_SESSION_COOKIE)?.value; + + if (!token) { + return { error: NextResponse.json({ error: 'Not authenticated' }, { status: 401 }) }; + } + + const payload = verifyAdminSession(token); + if (!payload) { + cookieStore.delete(ADMIN_SESSION_COOKIE); + return { error: NextResponse.json({ error: 'Session expired' }, { status: 401 }) }; + } + + return { payload }; +} + +/** + * Set the admin session cookie. + */ +export async function setAdminSessionCookie(): Promise { + const token = createAdminSession(); + const cookieStore = await cookies(); + cookieStore.set(ADMIN_SESSION_COOKIE, token, { + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + sameSite: 'lax', + path: '/', + maxAge: getSessionTTL(), + }); +} + +/** + * Clear the admin session cookie. + */ +export async function clearAdminSessionCookie(): Promise { + const cookieStore = await cookies(); + cookieStore.delete(ADMIN_SESSION_COOKIE); +} + +/** + * Get the client IP from the request headers. + */ +export function getClientIP(request: Request): string { + const forwarded = request.headers.get('x-forwarded-for'); + if (forwarded) { + return forwarded.split(',')[0].trim(); + } + return request.headers.get('x-real-ip') || '0.0.0.0'; +} diff --git a/lib/admin/types.ts b/lib/admin/types.ts new file mode 100644 index 00000000..cbbe78da --- /dev/null +++ b/lib/admin/types.ts @@ -0,0 +1,111 @@ +// Admin dashboard types + +export interface AdminData { + passwordHash: string; + createdAt: string; + lastLogin: string | null; + passwordChangedAt: string; +} + +export interface AdminSessionPayload { + role: 'admin'; + iat: number; + exp: number; +} + +export interface SettingRestriction { + locked?: boolean; + value?: unknown; + hidden?: boolean; + allowedValues?: unknown[]; + min?: number; + max?: number; +} + +export interface FeatureGates { + sidebarAppsEnabled: boolean; + userThemesEnabled: boolean; + settingsExportEnabled: boolean; + customKeywordsEnabled: boolean; + templatesEnabled: boolean; + calendarTasksEnabled: boolean; + smimeEnabled: boolean; + externalContentEnabled: boolean; + debugModeEnabled: boolean; + folderIconsEnabled: boolean; + hoverActionsConfigEnabled: boolean; +} + +export const DEFAULT_FEATURE_GATES: FeatureGates = { + sidebarAppsEnabled: true, + userThemesEnabled: true, + settingsExportEnabled: true, + customKeywordsEnabled: true, + templatesEnabled: true, + calendarTasksEnabled: true, + smimeEnabled: true, + externalContentEnabled: true, + debugModeEnabled: true, + folderIconsEnabled: true, + hoverActionsConfigEnabled: true, +}; + +export interface SettingsPolicy { + restrictions: Record; + features: FeatureGates; + defaults: Record; +} + +export const DEFAULT_POLICY: SettingsPolicy = { + restrictions: {}, + features: { ...DEFAULT_FEATURE_GATES }, + defaults: {}, +}; + +export interface AuditEntry { + ts: string; + action: string; + detail: Record; + ip: string; +} + +/** Config keys that map to environment variables */ +export const CONFIG_ENV_MAP: Record = { + appName: { envVar: 'APP_NAME', type: 'string', defaultValue: 'Webmail' }, + jmapServerUrl: { envVar: 'JMAP_SERVER_URL', type: 'url', defaultValue: '' }, + stalwartFeaturesEnabled: { envVar: 'STALWART_FEATURES', type: 'boolean', defaultValue: true }, + stalwartApiUrl: { envVar: 'STALWART_API_URL', type: 'url', defaultValue: '' }, + demoMode: { envVar: 'DEMO_MODE', type: 'boolean', defaultValue: false }, + devMode: { envVar: 'DEV_MOCK_JMAP', type: 'boolean', defaultValue: false }, + faviconUrl: { envVar: 'FAVICON_URL', type: 'url', defaultValue: '/branding/Bulwark_Favicon.svg' }, + appLogoLightUrl: { envVar: 'APP_LOGO_LIGHT_URL', type: 'url', defaultValue: '' }, + appLogoDarkUrl: { envVar: 'APP_LOGO_DARK_URL', type: 'url', defaultValue: '' }, + loginLogoLightUrl: { envVar: 'LOGIN_LOGO_LIGHT_URL', type: 'url', defaultValue: '/branding/Bulwark_Logo_Color.svg' }, + loginLogoDarkUrl: { envVar: 'LOGIN_LOGO_DARK_URL', type: 'url', defaultValue: '/branding/Bulwark_Logo_White.svg' }, + loginCompanyName: { envVar: 'LOGIN_COMPANY_NAME', type: 'string', defaultValue: '' }, + loginImprintUrl: { envVar: 'LOGIN_IMPRINT_URL', type: 'url', defaultValue: '' }, + loginPrivacyPolicyUrl: { envVar: 'LOGIN_PRIVACY_POLICY_URL', type: 'url', defaultValue: '' }, + loginWebsiteUrl: { envVar: 'LOGIN_WEBSITE_URL', type: 'url', defaultValue: '' }, + oauthEnabled: { envVar: 'OAUTH_ENABLED', type: 'boolean', defaultValue: false }, + oauthOnly: { envVar: 'OAUTH_ONLY', type: 'boolean', defaultValue: false }, + oauthClientId: { envVar: 'OAUTH_CLIENT_ID', type: 'string', defaultValue: '' }, + oauthClientSecret: { envVar: 'OAUTH_CLIENT_SECRET', type: 'string', defaultValue: '' }, + oauthIssuerUrl: { envVar: 'OAUTH_ISSUER_URL', type: 'url', defaultValue: '' }, + autoSsoEnabled: { envVar: 'AUTO_SSO_ENABLED', type: 'boolean', defaultValue: false }, + cookieSameSite: { envVar: 'COOKIE_SAME_SITE', type: 'enum', defaultValue: 'lax', enumValues: ['lax', 'strict', 'none'] }, + allowedFrameAncestors: { envVar: 'ALLOWED_FRAME_ANCESTORS', type: 'string', defaultValue: '' }, + parentOrigin: { envVar: 'NEXT_PUBLIC_PARENT_ORIGIN', type: 'string', defaultValue: '' }, + settingsSyncEnabled: { envVar: 'SETTINGS_SYNC_ENABLED', type: 'boolean', defaultValue: false }, + logFormat: { envVar: 'LOG_FORMAT', type: 'enum', defaultValue: 'text', enumValues: ['text', 'json'] }, + logLevel: { envVar: 'LOG_LEVEL', type: 'enum', defaultValue: 'info', enumValues: ['error', 'warn', 'info', 'debug'] }, + sessionSecret: { envVar: 'SESSION_SECRET', type: 'string', defaultValue: '' }, +}; + +/** Keys that should never be exposed to the client config endpoint */ +export const SENSITIVE_CONFIG_KEYS = new Set(['oauthClientSecret', 'sessionSecret']); + +/** Admin session cookie name */ +export const ADMIN_SESSION_COOKIE = 'admin_session'; + +/** Default admin session TTL in seconds */ +export const DEFAULT_ADMIN_SESSION_TTL = 3600; diff --git a/lib/builtin-themes.ts b/lib/builtin-themes.ts new file mode 100644 index 00000000..7ed880b6 --- /dev/null +++ b/lib/builtin-themes.ts @@ -0,0 +1,157 @@ +import type { InstalledTheme } from './plugin-types'; + +const nordCSS = ` +:root { + --color-border: #d8dee9; + --color-input: #d8dee9; + --color-ring: #81a1c1; + --color-background: #eceff4; + --color-foreground: #2e3440; + --color-primary: #5e81ac; + --color-primary-foreground: #eceff4; + --color-secondary: #e5e9f0; + --color-secondary-foreground: #2e3440; + --color-muted: #d8dee9; + --color-muted-foreground: #4c566a; + --color-accent: #81a1c1; + --color-accent-foreground: #2e3440; + --color-destructive: #bf616a; + --color-destructive-foreground: #eceff4; + --color-popover: #eceff4; + --color-popover-foreground: #2e3440; +} +.dark { + --color-border: #3b4252; + --color-input: #3b4252; + --color-ring: #88c0d0; + --color-background: #2e3440; + --color-foreground: #eceff4; + --color-primary: #88c0d0; + --color-primary-foreground: #2e3440; + --color-secondary: #3b4252; + --color-secondary-foreground: #eceff4; + --color-muted: #3b4252; + --color-muted-foreground: #d8dee9; + --color-accent: #434c5e; + --color-accent-foreground: #88c0d0; + --color-destructive: #bf616a; + --color-destructive-foreground: #eceff4; + --color-popover: #3b4252; + --color-popover-foreground: #eceff4; +}`; + +const catppuccinCSS = ` +:root { + --color-border: #ccd0da; + --color-input: #ccd0da; + --color-ring: #8839ef; + --color-background: #eff1f5; + --color-foreground: #4c4f69; + --color-primary: #8839ef; + --color-primary-foreground: #eff1f5; + --color-secondary: #e6e9ef; + --color-secondary-foreground: #4c4f69; + --color-muted: #dce0e8; + --color-muted-foreground: #6c6f85; + --color-accent: #8839ef; + --color-accent-foreground: #eff1f5; + --color-destructive: #d20f39; + --color-destructive-foreground: #eff1f5; + --color-popover: #eff1f5; + --color-popover-foreground: #4c4f69; +} +.dark { + --color-border: #45475a; + --color-input: #45475a; + --color-ring: #cba6f7; + --color-background: #1e1e2e; + --color-foreground: #cdd6f4; + --color-primary: #cba6f7; + --color-primary-foreground: #1e1e2e; + --color-secondary: #313244; + --color-secondary-foreground: #cdd6f4; + --color-muted: #313244; + --color-muted-foreground: #a6adc8; + --color-accent: #45475a; + --color-accent-foreground: #cba6f7; + --color-destructive: #f38ba8; + --color-destructive-foreground: #1e1e2e; + --color-popover: #313244; + --color-popover-foreground: #cdd6f4; +}`; + +const solarizedCSS = ` +:root { + --color-border: #eee8d5; + --color-input: #eee8d5; + --color-ring: #268bd2; + --color-background: #fdf6e3; + --color-foreground: #657b83; + --color-primary: #268bd2; + --color-primary-foreground: #fdf6e3; + --color-secondary: #eee8d5; + --color-secondary-foreground: #586e75; + --color-muted: #eee8d5; + --color-muted-foreground: #93a1a1; + --color-accent: #268bd2; + --color-accent-foreground: #fdf6e3; + --color-destructive: #dc322f; + --color-destructive-foreground: #fdf6e3; + --color-popover: #fdf6e3; + --color-popover-foreground: #657b83; +} +.dark { + --color-border: #073642; + --color-input: #073642; + --color-ring: #268bd2; + --color-background: #002b36; + --color-foreground: #839496; + --color-primary: #268bd2; + --color-primary-foreground: #002b36; + --color-secondary: #073642; + --color-secondary-foreground: #93a1a1; + --color-muted: #073642; + --color-muted-foreground: #586e75; + --color-accent: #073642; + --color-accent-foreground: #268bd2; + --color-destructive: #dc322f; + --color-destructive-foreground: #fdf6e3; + --color-popover: #073642; + --color-popover-foreground: #93a1a1; +}`; + +export const BUILTIN_THEMES: InstalledTheme[] = [ + { + id: 'builtin-nord', + name: 'Nord', + version: '1.0.0', + author: 'Built-in', + description: 'Arctic, north-bluish color palette inspired by nordtheme.com', + css: nordCSS, + variants: ['light', 'dark'], + enabled: true, + builtIn: true, + }, + { + id: 'builtin-catppuccin', + name: 'Catppuccin', + version: '1.0.0', + author: 'Built-in', + description: 'Soothing pastel theme with Latte (light) and Mocha (dark) variants', + css: catppuccinCSS, + variants: ['light', 'dark'], + enabled: true, + builtIn: true, + }, + { + id: 'builtin-solarized', + name: 'Solarized', + version: '1.0.0', + author: 'Built-in', + description: 'Precision colors for machines and people by Ethan Schoonover', + css: solarizedCSS, + variants: ['light', 'dark'], + enabled: true, + builtIn: true, + }, +]; diff --git a/lib/plugin-api.ts b/lib/plugin-api.ts new file mode 100644 index 00000000..cc02b017 --- /dev/null +++ b/lib/plugin-api.ts @@ -0,0 +1,592 @@ +// PluginAPI factory — builds the sandboxed API facade for each plugin + +import type { + Disposable, + InstalledPlugin, + Permission, + ToolbarAction, + BannerFactory, + SettingsSection, + ComposerAction, + SidebarWidget, + ContextMenuItem, + KeyboardShortcut, + SlotName, +} from './plugin-types'; +import { IMPLICIT_PERMISSIONS as IMPLICIT } from './plugin-types'; +import { + emailHooks, calendarHooks, contactHooks, fileHooks, + authHooks, settingsHooks, identityHooks, filterHooks, + taskHooks, templateHooks, smimeHooks, vacationHooks, + uiHooks, themeHooks, toastHooks, dragDropHooks, + keyboardHooks, appLifecycleHooks, accountSecurityHooks, + sidebarAppHooks, +} from './plugin-hooks'; +import { toast as appToast } from '@/stores/toast-store'; + +// ─── Permission helpers ────────────────────────────────────── + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function getPluginExternals(): any { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return (globalThis as any).__PLUGIN_EXTERNALS__; +} + +function hasPermission(plugin: InstalledPlugin, perm: Permission): boolean { + if ((IMPLICIT as readonly string[]).includes(perm)) return true; + return plugin.permissions.includes(perm); +} + +function requirePermission(plugin: InstalledPlugin, perm: Permission): void { + if (!hasPermission(plugin, perm)) { + throw new Error(`Plugin "${plugin.id}" lacks permission "${perm}"`); + } +} + +/** Returns a no-op disposable when permission is missing (silent failure) */ +function guardedHook unknown>( + plugin: InstalledPlugin, + perm: Permission, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + bus: { register: (pluginId: string, handler: any, order?: number) => Disposable }, + handler: T, + order: number = 100, +): Disposable { + if (!hasPermission(plugin, perm)) { + return { dispose: () => {} }; + } + return bus.register(plugin.id, handler, order); +} + +// ─── Plugin-scoped storage ─────────────────────────────────── + +function createPluginStorage(pluginId: string) { + const prefix = `plugin:${pluginId}:`; + + return { + get: (key: string): T | null => { + if (typeof window === 'undefined') return null; + const raw = localStorage.getItem(prefix + key); + if (raw === null) return null; + try { return JSON.parse(raw) as T; } catch { return null; } + }, + set: (key: string, value: T): void => { + if (typeof window === 'undefined') return; + localStorage.setItem(prefix + key, JSON.stringify(value)); + }, + remove: (key: string): void => { + if (typeof window === 'undefined') return; + localStorage.removeItem(prefix + key); + }, + keys: (): string[] => { + if (typeof window === 'undefined') return []; + const keys: string[] = []; + for (let i = 0; i < localStorage.length; i++) { + const k = localStorage.key(i); + if (k?.startsWith(prefix)) keys.push(k.slice(prefix.length)); + } + return keys; + }, + }; +} + +// ─── Plugin-scoped logger ──────────────────────────────────── + +function createPluginLogger(pluginId: string) { + const tag = `[plugin:${pluginId}]`; + return { + debug: (...args: unknown[]) => console.debug(tag, ...args), + info: (...args: unknown[]) => console.info(tag, ...args), + warn: (...args: unknown[]) => console.warn(tag, ...args), + error: (...args: unknown[]) => console.error(tag, ...args), + }; +} + +// ─── PluginAPI interface ───────────────────────────────────── + +export interface PluginAPI { + plugin: { id: string; version: string; settings: Record }; + ui: { + registerToolbarAction: (action: ToolbarAction) => Disposable; + registerEmailBanner: (factory: BannerFactory) => Disposable; + registerEmailFooter: (component: React.ComponentType) => Disposable; + registerSettingsSection: (section: SettingsSection) => Disposable; + registerComposerAction: (action: ComposerAction) => Disposable; + registerSidebarWidget: (widget: SidebarWidget) => Disposable; + registerContextMenuItem: (item: ContextMenuItem) => Disposable; + registerNavigationRailItem: (component: React.ComponentType) => Disposable; + }; + hooks: PluginHooksAPI; + toast: { + success: (message: string) => void; + error: (message: string) => void; + info: (message: string) => void; + warning: (message: string) => void; + }; + storage: ReturnType; + log: ReturnType; +} + +// Simplified hooks API type (all hooks return Disposable) +export interface PluginHooksAPI { + // Email + onEmailOpen: (handler: (...args: unknown[]) => unknown) => Disposable; + onEmailClose: (handler: () => void) => Disposable; + onEmailContentRender: (handler: (...args: unknown[]) => unknown) => Disposable; + onThreadExpand: (handler: (...args: unknown[]) => unknown) => Disposable; + onComposerOpen: (handler: (...args: unknown[]) => unknown) => Disposable; + onBeforeEmailSend: (handler: (...args: unknown[]) => unknown) => Disposable; + onAfterEmailSend: (handler: (...args: unknown[]) => unknown) => Disposable; + onDraftAutoSave: (handler: (...args: unknown[]) => unknown) => Disposable; + onBeforeEmailDelete: (handler: (...args: unknown[]) => unknown) => Disposable; + onAfterEmailDelete: (handler: (...args: unknown[]) => unknown) => Disposable; + onBeforeEmailMove: (handler: (...args: unknown[]) => unknown) => Disposable; + onAfterEmailMove: (handler: (...args: unknown[]) => unknown) => Disposable; + onEmailReadStateChange: (handler: (...args: unknown[]) => unknown) => Disposable; + onEmailStarToggle: (handler: (...args: unknown[]) => unknown) => Disposable; + onEmailSpamToggle: (handler: (...args: unknown[]) => unknown) => Disposable; + onEmailKeywordChange: (handler: (...args: unknown[]) => unknown) => Disposable; + onMailboxChange: (handler: (...args: unknown[]) => unknown) => Disposable; + onMailboxesRefresh: (handler: (...args: unknown[]) => unknown) => Disposable; + onMailboxCreate: (handler: (...args: unknown[]) => unknown) => Disposable; + onMailboxRename: (handler: (...args: unknown[]) => unknown) => Disposable; + onMailboxDelete: (handler: (...args: unknown[]) => unknown) => Disposable; + onMailboxEmpty: (handler: (...args: unknown[]) => unknown) => Disposable; + onSearch: (handler: (...args: unknown[]) => unknown) => Disposable; + onSearchResults: (handler: (...args: unknown[]) => unknown) => Disposable; + onEmailSelectionChange: (handler: (...args: unknown[]) => unknown) => Disposable; + onNewEmailReceived: (handler: (...args: unknown[]) => unknown) => Disposable; + onPushConnectionChange: (handler: (...args: unknown[]) => unknown) => Disposable; + onQuotaChange: (handler: (...args: unknown[]) => unknown) => Disposable; + // Calendar + onCalendarEventOpen: (handler: (...args: unknown[]) => unknown) => Disposable; + onBeforeEventCreate: (handler: (...args: unknown[]) => unknown) => Disposable; + onAfterEventCreate: (handler: (...args: unknown[]) => unknown) => Disposable; + onBeforeEventUpdate: (handler: (...args: unknown[]) => unknown) => Disposable; + onAfterEventUpdate: (handler: (...args: unknown[]) => unknown) => Disposable; + onBeforeEventDelete: (handler: (...args: unknown[]) => unknown) => Disposable; + onAfterEventDelete: (handler: (...args: unknown[]) => unknown) => Disposable; + onEventRsvp: (handler: (...args: unknown[]) => unknown) => Disposable; + onEventsImport: (handler: (...args: unknown[]) => unknown) => Disposable; + onCalendarDateChange: (handler: (...args: unknown[]) => unknown) => Disposable; + onCalendarViewChange: (handler: (...args: unknown[]) => unknown) => Disposable; + onCalendarChange: (handler: (...args: unknown[]) => unknown) => Disposable; + onCalendarVisibilityToggle: (handler: (...args: unknown[]) => unknown) => Disposable; + onICalSubscriptionChange: (handler: (...args: unknown[]) => unknown) => Disposable; + onCalendarAlert: (handler: (...args: unknown[]) => unknown) => Disposable; + onCalendarAlertAcknowledge: (handler: (...args: unknown[]) => unknown) => Disposable; + // Contacts + onContactOpen: (handler: (...args: unknown[]) => unknown) => Disposable; + onBeforeContactCreate: (handler: (...args: unknown[]) => unknown) => Disposable; + onAfterContactCreate: (handler: (...args: unknown[]) => unknown) => Disposable; + onBeforeContactUpdate: (handler: (...args: unknown[]) => unknown) => Disposable; + onAfterContactUpdate: (handler: (...args: unknown[]) => unknown) => Disposable; + onBeforeContactDelete: (handler: (...args: unknown[]) => unknown) => Disposable; + onAfterContactDelete: (handler: (...args: unknown[]) => unknown) => Disposable; + onContactsImport: (handler: (...args: unknown[]) => unknown) => Disposable; + onContactSelectionChange: (handler: (...args: unknown[]) => unknown) => Disposable; + onContactGroupChange: (handler: (...args: unknown[]) => unknown) => Disposable; + onContactGroupMemberChange: (handler: (...args: unknown[]) => unknown) => Disposable; + onContactMove: (handler: (...args: unknown[]) => unknown) => Disposable; + // Files + onFileNavigate: (handler: (...args: unknown[]) => unknown) => Disposable; + onBeforeFileUpload: (handler: (...args: unknown[]) => unknown) => Disposable; + onAfterFileUpload: (handler: (...args: unknown[]) => unknown) => Disposable; + onFileDownload: (handler: (...args: unknown[]) => unknown) => Disposable; + onFileUploadCancel: (handler: (...args: unknown[]) => unknown) => Disposable; + onDirectoryCreate: (handler: (...args: unknown[]) => unknown) => Disposable; + onBeforeFileDelete: (handler: (...args: unknown[]) => unknown) => Disposable; + onAfterFileDelete: (handler: (...args: unknown[]) => unknown) => Disposable; + onFileRename: (handler: (...args: unknown[]) => unknown) => Disposable; + onFileMove: (handler: (...args: unknown[]) => unknown) => Disposable; + onFileCopy: (handler: (...args: unknown[]) => unknown) => Disposable; + onFileDuplicate: (handler: (...args: unknown[]) => unknown) => Disposable; + onFileFavoriteToggle: (handler: (...args: unknown[]) => unknown) => Disposable; + onFileSelectionChange: (handler: (...args: unknown[]) => unknown) => Disposable; + onFileUndo: (handler: (...args: unknown[]) => unknown) => Disposable; + // Auth + onLogin: (handler: (...args: unknown[]) => unknown) => Disposable; + onBeforeLogout: (handler: () => void) => Disposable; + onAfterLogout: (handler: () => void) => Disposable; + onAccountSwitch: (handler: (...args: unknown[]) => unknown) => Disposable; + onAccountAdd: (handler: (...args: unknown[]) => unknown) => Disposable; + onAccountRemove: (handler: (...args: unknown[]) => unknown) => Disposable; + onTokenRefresh: (handler: () => void) => Disposable; + onAuthReady: (handler: (...args: unknown[]) => unknown) => Disposable; + // Settings + onSettingChange: (handler: (...args: unknown[]) => unknown) => Disposable; + onSettingsExport: (handler: () => void) => Disposable; + onSettingsImport: (handler: (...args: unknown[]) => unknown) => Disposable; + onSettingsReset: (handler: () => void) => Disposable; + onSettingsSync: (handler: (...args: unknown[]) => unknown) => Disposable; + onKeywordChange: (handler: (...args: unknown[]) => unknown) => Disposable; + onTrustedSenderChange: (handler: (...args: unknown[]) => unknown) => Disposable; + // Identity + onIdentitiesLoaded: (handler: (...args: unknown[]) => unknown) => Disposable; + onIdentityCreate: (handler: (...args: unknown[]) => unknown) => Disposable; + onIdentityUpdate: (handler: (...args: unknown[]) => unknown) => Disposable; + onIdentityDelete: (handler: (...args: unknown[]) => unknown) => Disposable; + onIdentitySelect: (handler: (...args: unknown[]) => unknown) => Disposable; + onSignatureRender: (handler: (...args: unknown[]) => unknown) => Disposable; + // Filters + onFiltersLoaded: (handler: (...args: unknown[]) => unknown) => Disposable; + onFilterRuleChange: (handler: (...args: unknown[]) => unknown) => Disposable; + onFiltersSave: (handler: (...args: unknown[]) => unknown) => Disposable; + onSieveScriptChange: (handler: (...args: unknown[]) => unknown) => Disposable; + // Tasks + onTasksLoaded: (handler: (...args: unknown[]) => unknown) => Disposable; + onTaskCreate: (handler: (...args: unknown[]) => unknown) => Disposable; + onTaskUpdate: (handler: (...args: unknown[]) => unknown) => Disposable; + onTaskDelete: (handler: (...args: unknown[]) => unknown) => Disposable; + onTaskToggleComplete: (handler: (...args: unknown[]) => unknown) => Disposable; + onTaskFilterChange: (handler: (...args: unknown[]) => unknown) => Disposable; + // Templates + onTemplateCreate: (handler: (...args: unknown[]) => unknown) => Disposable; + onTemplateUpdate: (handler: (...args: unknown[]) => unknown) => Disposable; + onTemplateDelete: (handler: (...args: unknown[]) => unknown) => Disposable; + onTemplateApply: (handler: (...args: unknown[]) => unknown) => Disposable; + onTemplatesImport: (handler: (...args: unknown[]) => unknown) => Disposable; + onTemplateRender: (handler: (...args: unknown[]) => unknown) => Disposable; + // S/MIME + onSmimeKeyImport: (handler: (...args: unknown[]) => unknown) => Disposable; + onSmimeCertImport: (handler: (...args: unknown[]) => unknown) => Disposable; + onSmimeKeyStateChange: (handler: (...args: unknown[]) => unknown) => Disposable; + onSmimeDefaultsChange: (handler: (...args: unknown[]) => unknown) => Disposable; + // Vacation + onVacationLoaded: (handler: (...args: unknown[]) => unknown) => Disposable; + onVacationUpdate: (handler: (...args: unknown[]) => unknown) => Disposable; + // UI + onViewChange: (handler: (...args: unknown[]) => unknown) => Disposable; + onSidebarToggle: (handler: (...args: unknown[]) => unknown) => Disposable; + onSidebarCollapse: (handler: (...args: unknown[]) => unknown) => Disposable; + onDeviceTypeChange: (handler: (...args: unknown[]) => unknown) => Disposable; + onColumnResize: (handler: (...args: unknown[]) => unknown) => Disposable; + onMobileBack: (handler: () => void) => Disposable; + onMobileViewSwitch: (handler: (...args: unknown[]) => unknown) => Disposable; + // Theme + onThemeChange: (handler: (...args: unknown[]) => unknown) => Disposable; + onCustomThemeChange: (handler: (...args: unknown[]) => unknown) => Disposable; + onLocaleChange: (handler: (...args: unknown[]) => unknown) => Disposable; + // Toast + onToastShow: (handler: (...args: unknown[]) => unknown) => Disposable; + onToastDismiss: (handler: (...args: unknown[]) => unknown) => Disposable; + onBrowserNotification: (handler: (...args: unknown[]) => unknown) => Disposable; + // Drag & Drop + onDragStart: (handler: (...args: unknown[]) => unknown) => Disposable; + onDragEnd: (handler: (...args: unknown[]) => unknown) => Disposable; + onEmailDrop: (handler: (...args: unknown[]) => unknown) => Disposable; + onTagDrop: (handler: (...args: unknown[]) => unknown) => Disposable; + // Keyboard + registerShortcut: (shortcut: KeyboardShortcut) => Disposable; + onBeforeShortcut: (handler: (...args: unknown[]) => unknown) => Disposable; + onAfterShortcut: (handler: (...args: unknown[]) => unknown) => Disposable; + // App Lifecycle + onAppReady: (handler: () => void) => Disposable; + onVisibilityChange: (handler: (...args: unknown[]) => unknown) => Disposable; + onBeforeUnload: (handler: () => void) => Disposable; + onAppError: (handler: (...args: unknown[]) => unknown) => Disposable; + onInterval: (handler: () => void, intervalMs: number) => Disposable; + // Account Security + onPasswordChange: (handler: () => void) => Disposable; + onTotpChange: (handler: (...args: unknown[]) => unknown) => Disposable; + onAppPasswordChange: (handler: (...args: unknown[]) => unknown) => Disposable; + onEncryptionChange: (handler: () => void) => Disposable; + onDisplayNameChange: (handler: (...args: unknown[]) => unknown) => Disposable; + // Sidebar Apps + onSidebarAppOpen: (handler: (...args: unknown[]) => unknown) => Disposable; + onSidebarAppClose: (handler: (...args: unknown[]) => unknown) => Disposable; + onSidebarAppChange: (handler: (...args: unknown[]) => unknown) => Disposable; +} + +// ─── Permission mapping for hooks ──────────────────────────── + +const HOOK_PERMISSIONS: Record = { + // Email + onEmailOpen: 'email:read', onEmailClose: 'email:read', + onEmailContentRender: 'email:read', onThreadExpand: 'email:read', + onComposerOpen: 'email:read', onDraftAutoSave: 'email:read', + onMailboxChange: 'email:read', onMailboxesRefresh: 'email:read', + onSearch: 'email:read', onSearchResults: 'email:read', + onEmailSelectionChange: 'email:read', onNewEmailReceived: 'email:read', + onPushConnectionChange: 'email:read', onQuotaChange: 'email:read', + onBeforeEmailSend: 'email:send', onAfterEmailSend: 'email:send', + onBeforeEmailDelete: 'email:write', onAfterEmailDelete: 'email:write', + onBeforeEmailMove: 'email:write', onAfterEmailMove: 'email:write', + onEmailReadStateChange: 'email:write', onEmailStarToggle: 'email:write', + onEmailSpamToggle: 'email:write', onEmailKeywordChange: 'email:write', + onMailboxCreate: 'email:write', onMailboxRename: 'email:write', + onMailboxDelete: 'email:write', onMailboxEmpty: 'email:write', + // Calendar + onCalendarEventOpen: 'calendar:read', onCalendarDateChange: 'calendar:read', + onCalendarViewChange: 'calendar:read', onCalendarVisibilityToggle: 'calendar:read', + onCalendarAlert: 'calendar:read', onCalendarAlertAcknowledge: 'calendar:read', + onBeforeEventCreate: 'calendar:write', onAfterEventCreate: 'calendar:write', + onBeforeEventUpdate: 'calendar:write', onAfterEventUpdate: 'calendar:write', + onBeforeEventDelete: 'calendar:write', onAfterEventDelete: 'calendar:write', + onEventRsvp: 'calendar:write', onEventsImport: 'calendar:write', + onCalendarChange: 'calendar:write', onICalSubscriptionChange: 'calendar:write', + // Contacts + onContactOpen: 'contacts:read', onContactSelectionChange: 'contacts:read', + onBeforeContactCreate: 'contacts:write', onAfterContactCreate: 'contacts:write', + onBeforeContactUpdate: 'contacts:write', onAfterContactUpdate: 'contacts:write', + onBeforeContactDelete: 'contacts:write', onAfterContactDelete: 'contacts:write', + onContactsImport: 'contacts:write', onContactGroupChange: 'contacts:write', + onContactGroupMemberChange: 'contacts:write', onContactMove: 'contacts:write', + // Files + onFileNavigate: 'files:read', onFileDownload: 'files:read', onFileSelectionChange: 'files:read', + onBeforeFileUpload: 'files:write', onAfterFileUpload: 'files:write', + onFileUploadCancel: 'files:write', onDirectoryCreate: 'files:write', + onBeforeFileDelete: 'files:write', onAfterFileDelete: 'files:write', + onFileRename: 'files:write', onFileMove: 'files:write', onFileCopy: 'files:write', + onFileDuplicate: 'files:write', onFileFavoriteToggle: 'files:write', onFileUndo: 'files:write', + // Auth + onLogin: 'auth:observe', onBeforeLogout: 'auth:observe', onAfterLogout: 'auth:observe', + onAccountSwitch: 'auth:observe', onAccountAdd: 'auth:observe', onAccountRemove: 'auth:observe', + onTokenRefresh: 'auth:observe', onAuthReady: 'auth:observe', + // Settings + onSettingChange: 'settings:read', onSettingsExport: 'settings:read', + onSettingsImport: 'settings:read', onSettingsReset: 'settings:read', + onSettingsSync: 'settings:read', onKeywordChange: 'settings:read', + onTrustedSenderChange: 'settings:read', + // Identity + onIdentitiesLoaded: 'identity:read', onIdentitySelect: 'identity:read', + onSignatureRender: 'identity:read', + onIdentityCreate: 'identity:write', onIdentityUpdate: 'identity:write', + onIdentityDelete: 'identity:write', + // Filters + onFiltersLoaded: 'filters:read', + onFilterRuleChange: 'filters:write', onFiltersSave: 'filters:write', + onSieveScriptChange: 'filters:write', + // Tasks + onTasksLoaded: 'tasks:read', onTaskFilterChange: 'tasks:read', + onTaskCreate: 'tasks:write', onTaskUpdate: 'tasks:write', + onTaskDelete: 'tasks:write', onTaskToggleComplete: 'tasks:write', + // Templates + onTemplateApply: 'templates:read', onTemplateRender: 'templates:read', + onTemplateCreate: 'templates:write', onTemplateUpdate: 'templates:write', + onTemplateDelete: 'templates:write', onTemplatesImport: 'templates:write', + // S/MIME + onSmimeKeyImport: 'smime:read', onSmimeCertImport: 'smime:read', + onSmimeKeyStateChange: 'smime:read', onSmimeDefaultsChange: 'smime:read', + // Vacation + onVacationLoaded: 'vacation:read', onVacationUpdate: 'vacation:write', + // UI + onViewChange: 'ui:observe', onSidebarToggle: 'ui:observe', + onSidebarCollapse: 'ui:observe', onDeviceTypeChange: 'ui:observe', + onColumnResize: 'ui:observe', onMobileBack: 'ui:observe', + onMobileViewSwitch: 'ui:observe', + // Theme + onThemeChange: 'ui:observe', onCustomThemeChange: 'ui:observe', + onLocaleChange: 'ui:observe', + // Toast + onToastShow: 'ui:observe', onToastDismiss: 'ui:observe', + onBrowserNotification: 'ui:observe', + // Drag & Drop + onDragStart: 'ui:observe', onDragEnd: 'ui:observe', + onEmailDrop: 'ui:observe', onTagDrop: 'ui:observe', + // Keyboard + registerShortcut: 'ui:keyboard', onBeforeShortcut: 'ui:keyboard', + onAfterShortcut: 'ui:keyboard', + // App Lifecycle + onAppReady: 'app:lifecycle', onVisibilityChange: 'app:lifecycle', + onBeforeUnload: 'app:lifecycle', onAppError: 'app:lifecycle', + onInterval: 'app:lifecycle', + // Account Security + onPasswordChange: 'security:read', onTotpChange: 'security:read', + onAppPasswordChange: 'security:read', onEncryptionChange: 'security:read', + onDisplayNameChange: 'security:read', + // Sidebar Apps + onSidebarAppOpen: 'ui:observe', onSidebarAppClose: 'ui:observe', + onSidebarAppChange: 'ui:observe', +}; + +// Map hook names → actual HookBus instances +const HOOK_BUSES: Record unknown, order?: number) => Disposable }> = { + // Email + ...Object.fromEntries(Object.entries(emailHooks)), + // Calendar + ...Object.fromEntries(Object.entries(calendarHooks)), + // Contacts + ...Object.fromEntries(Object.entries(contactHooks)), + // Files + ...Object.fromEntries(Object.entries(fileHooks)), + // Auth + ...Object.fromEntries(Object.entries(authHooks)), + // Settings + ...Object.fromEntries(Object.entries(settingsHooks)), + // Identity + ...Object.fromEntries(Object.entries(identityHooks)), + // Filters + ...Object.fromEntries(Object.entries(filterHooks)), + // Tasks + ...Object.fromEntries(Object.entries(taskHooks)), + // Templates + ...Object.fromEntries(Object.entries(templateHooks)), + // S/MIME + ...Object.fromEntries(Object.entries(smimeHooks)), + // Vacation + ...Object.fromEntries(Object.entries(vacationHooks)), + // UI + ...Object.fromEntries(Object.entries(uiHooks)), + // Theme + ...Object.fromEntries(Object.entries(themeHooks)), + // Toast + ...Object.fromEntries(Object.entries(toastHooks)), + // Drag & Drop + ...Object.fromEntries(Object.entries(dragDropHooks)), + // Keyboard + ...Object.fromEntries(Object.entries(keyboardHooks)), + // App Lifecycle + ...Object.fromEntries(Object.entries(appLifecycleHooks)), + // Account Security + ...Object.fromEntries(Object.entries(accountSecurityHooks)), + // Sidebar Apps + ...Object.fromEntries(Object.entries(sidebarAppHooks)), +}; + +// ─── Slot registration bridge ──────────────────────────────── +// Lazy import to avoid circular dependency — plugin-store imports plugin-api indirectly + +let registerSlotFn: ((name: SlotName, reg: { pluginId: string; component: React.ComponentType>; order: number }) => Disposable) | null = null; + +export function setSlotRegistrationBridge(fn: typeof registerSlotFn): void { + registerSlotFn = fn; +} + +function registerSlot( + pluginId: string, + slotName: SlotName, + component: React.ComponentType>, + order: number = 100, +): Disposable { + if (!registerSlotFn) { + console.warn(`[plugin:${pluginId}] Slot registration not available yet`); + return { dispose: () => {} }; + } + return registerSlotFn(slotName, { pluginId, component, order }); +} + +// ─── Factory ───────────────────────────────────────────────── + +export function createPluginAPI(plugin: InstalledPlugin): PluginAPI { + // Build hooks proxy — each hook method checks permission and registers on the right bus + const hooks: PluginHooksAPI = {} as PluginHooksAPI; + + for (const [hookName, bus] of Object.entries(HOOK_BUSES)) { + const perm = HOOK_PERMISSIONS[hookName]; + if (!perm) continue; + + if (hookName === 'onInterval') { + // Special: onInterval takes (handler, intervalMs) + (hooks as unknown as Record)[hookName] = (handler: () => void, intervalMs: number) => { + if (!hasPermission(plugin, perm)) return { dispose: () => {} }; + const safeMs = Math.max(intervalMs, 60_000); // min 60s + const id = setInterval(handler, safeMs); + return { dispose: () => clearInterval(id) }; + }; + } else if (hookName === 'registerShortcut') { + // Special: registerShortcut takes a KeyboardShortcut object + (hooks as unknown as Record)[hookName] = (shortcut: KeyboardShortcut) => { + return guardedHook(plugin, perm, bus, shortcut.handler); + }; + } else { + (hooks as unknown as Record)[hookName] = (handler: (...args: unknown[]) => unknown) => { + return guardedHook(plugin, perm, bus, handler); + }; + } + } + + return { + plugin: { + id: plugin.id, + version: plugin.version, + settings: { ...plugin.settings }, + }, + + ui: { + registerToolbarAction: (action: ToolbarAction) => { + requirePermission(plugin, 'ui:toolbar'); + const Component = () => { + const externals = getPluginExternals(); + const React = externals?.React; + if (!React) return null; + const createElement = (React as { createElement: typeof import('react').createElement }).createElement; + return createElement('button', { + onClick: action.onClick, + className: 'plugin-toolbar-action', + title: action.label, + }, action.label); + }; + return registerSlot(plugin.id, 'toolbar-actions', Component as React.ComponentType>, action.order ?? 100); + }, + + registerEmailBanner: (factory: BannerFactory) => { + requirePermission(plugin, 'ui:email-banner'); + return registerSlot(plugin.id, 'email-banner', factory.render as unknown as React.ComponentType>, 100); + }, + + registerEmailFooter: (component: React.ComponentType) => { + requirePermission(plugin, 'ui:email-footer'); + return registerSlot(plugin.id, 'email-footer', component as React.ComponentType>, 100); + }, + + registerSettingsSection: (section: SettingsSection) => { + requirePermission(plugin, 'ui:settings-section'); + return registerSlot(plugin.id, 'settings-section', section.render as React.ComponentType>, 100); + }, + + registerComposerAction: (action: ComposerAction) => { + requirePermission(plugin, 'ui:composer-toolbar'); + const Component = () => { + const externals = getPluginExternals(); + const React = externals?.React; + if (!React) return null; + const createElement = (React as { createElement: typeof import('react').createElement }).createElement; + return createElement('button', { + onClick: action.onClick, + className: 'plugin-composer-action', + title: action.label, + }, action.label); + }; + return registerSlot(plugin.id, 'composer-toolbar', Component as React.ComponentType>, action.order ?? 100); + }, + + registerSidebarWidget: (widget: SidebarWidget) => { + requirePermission(plugin, 'ui:sidebar-widget'); + return registerSlot(plugin.id, 'sidebar-widget', widget.render as React.ComponentType>, widget.order ?? 100); + }, + + registerContextMenuItem: (item: ContextMenuItem) => { + requirePermission(plugin, 'ui:context-menu'); + const Component = () => { + const externals = getPluginExternals(); + const React = externals?.React; + if (!React) return null; + const createElement = (React as { createElement: typeof import('react').createElement }).createElement; + return createElement('button', { + onClick: () => item.onClick([]), + className: 'plugin-context-menu-item', + }, item.label); + }; + return registerSlot(plugin.id, 'context-menu-email', Component as React.ComponentType>, item.order ?? 100); + }, + + registerNavigationRailItem: (component: React.ComponentType) => { + requirePermission(plugin, 'ui:navigation-rail'); + return registerSlot(plugin.id, 'navigation-rail-bottom', component as React.ComponentType>, 100); + }, + }, + + hooks, + + toast: { + success: (message: string) => appToast.success(message), + error: (message: string) => appToast.error(message), + info: (message: string) => appToast.info(message), + warning: (message: string) => appToast.warning(message), + }, + + storage: createPluginStorage(plugin.id), + log: createPluginLogger(plugin.id), + }; +} diff --git a/lib/plugin-hooks.ts b/lib/plugin-hooks.ts new file mode 100644 index 00000000..2f7db299 --- /dev/null +++ b/lib/plugin-hooks.ts @@ -0,0 +1,420 @@ +// Plugin Hook Bus — event bus system for plugin lifecycle hooks + +import type { Disposable } from './plugin-types'; + +// ─── Error Tracker (Circuit Breaker) ───────────────────────── + +interface ErrorRecord { + timestamps: number[]; + disabled: boolean; +} + +const ERROR_THRESHOLD = 3; +const ERROR_WINDOW_MS = 60_000; + +class PluginErrorTracker { + private records = new Map(); + private onAutoDisable?: (pluginId: string, error: unknown) => void; + + setAutoDisableCallback(cb: (pluginId: string, error: unknown) => void): void { + this.onAutoDisable = cb; + } + + record(pluginId: string, error: unknown): void { + const now = Date.now(); + let rec = this.records.get(pluginId); + if (!rec) { + rec = { timestamps: [], disabled: false }; + this.records.set(pluginId, rec); + } + + // Prune old timestamps + rec.timestamps = rec.timestamps.filter(t => now - t < ERROR_WINDOW_MS); + rec.timestamps.push(now); + + console.error(`[plugin:${pluginId}] Hook error:`, error); + + if (rec.timestamps.length >= ERROR_THRESHOLD && !rec.disabled) { + rec.disabled = true; + console.error(`[plugin:${pluginId}] Auto-disabled after ${ERROR_THRESHOLD} errors in ${ERROR_WINDOW_MS / 1000}s`); + this.onAutoDisable?.(pluginId, error); + } + } + + isDisabled(pluginId: string): boolean { + return this.records.get(pluginId)?.disabled ?? false; + } + + reset(pluginId: string): void { + this.records.delete(pluginId); + } + + resetAll(): void { + this.records.clear(); + } +} + +export const pluginErrorTracker = new PluginErrorTracker(); + +// ─── Timeout Helper ────────────────────────────────────────── + +const DEFAULT_TIMEOUT_MS = 5000; + +function withTimeout(promise: T | Promise, ms: number = DEFAULT_TIMEOUT_MS): Promise { + if (!(promise instanceof Promise)) return Promise.resolve(promise); + return new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error(`Hook timed out after ${ms}ms`)), ms); + promise.then( + (val) => { clearTimeout(timer); resolve(val); }, + (err) => { clearTimeout(timer); reject(err); }, + ); + }); +} + +// ─── HookBus ───────────────────────────────────────────────── + +interface HookEntry unknown> { + pluginId: string; + handler: T; + order: number; +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export class HookBus any> { + private handlers: HookEntry[] = []; + + register(pluginId: string, handler: T, order: number = 100): Disposable { + const entry: HookEntry = { pluginId, handler, order }; + this.handlers.push(entry); + this.handlers.sort((a, b) => a.order - b.order); + return { + dispose: () => { + this.handlers = this.handlers.filter(h => h !== entry); + }, + }; + } + + /** Remove all handlers for a given plugin */ + removePlugin(pluginId: string): void { + this.handlers = this.handlers.filter(h => h.pluginId !== pluginId); + } + + /** Remove all handlers */ + clear(): void { + this.handlers = []; + } + + get size(): number { + return this.handlers.length; + } + + /** Fire all handlers (observer pattern — no return values used) */ + async emit(...args: Parameters): Promise { + for (const { pluginId, handler } of this.handlers) { + if (pluginErrorTracker.isDisabled(pluginId)) continue; + try { + await withTimeout(handler(...args)); + } catch (err) { + pluginErrorTracker.record(pluginId, err); + } + } + } + + /** Synchronous emit for performance-critical paths */ + emitSync(...args: Parameters): void { + for (const { pluginId, handler } of this.handlers) { + if (pluginErrorTracker.isDisabled(pluginId)) continue; + try { + handler(...args); + } catch (err) { + pluginErrorTracker.record(pluginId, err); + } + } + } + + /** Fire handlers as interceptors — any returning false cancels the operation */ + async intercept(...args: Parameters): Promise { + for (const { pluginId, handler } of this.handlers) { + if (pluginErrorTracker.isDisabled(pluginId)) continue; + try { + const result = await withTimeout(handler(...args)); + if (result === false) return false; + } catch (err) { + pluginErrorTracker.record(pluginId, err); + } + } + return true; + } + + /** Fire handlers as transforms — each receives the output of the previous */ + async transform(initial: V, ...rest: unknown[]): Promise { + let value = initial; + for (const { pluginId, handler } of this.handlers) { + if (pluginErrorTracker.isDisabled(pluginId)) continue; + try { + const result = await withTimeout(handler(value, ...rest)); + if (result !== undefined && result !== false) { + value = result as V; + } + } catch (err) { + pluginErrorTracker.record(pluginId, err); + } + } + return value; + } +} + +// ─── All Hook Buses (one per hook across all 20 domains) ───── + +// §7.1 Email Hooks +export const emailHooks = { + onEmailOpen: new HookBus(), + onEmailClose: new HookBus(), + onEmailContentRender: new HookBus(), + onThreadExpand: new HookBus(), + onComposerOpen: new HookBus(), + onBeforeEmailSend: new HookBus(), + onAfterEmailSend: new HookBus(), + onDraftAutoSave: new HookBus(), + onBeforeEmailDelete: new HookBus(), + onAfterEmailDelete: new HookBus(), + onBeforeEmailMove: new HookBus(), + onAfterEmailMove: new HookBus(), + onEmailReadStateChange: new HookBus(), + onEmailStarToggle: new HookBus(), + onEmailSpamToggle: new HookBus(), + onEmailKeywordChange: new HookBus(), + onMailboxChange: new HookBus(), + onMailboxesRefresh: new HookBus(), + onMailboxCreate: new HookBus(), + onMailboxRename: new HookBus(), + onMailboxDelete: new HookBus(), + onMailboxEmpty: new HookBus(), + onSearch: new HookBus(), + onSearchResults: new HookBus(), + onEmailSelectionChange: new HookBus(), + onNewEmailReceived: new HookBus(), + onPushConnectionChange: new HookBus(), + onQuotaChange: new HookBus(), +}; + +// §7.2 Calendar Hooks +export const calendarHooks = { + onCalendarEventOpen: new HookBus(), + onBeforeEventCreate: new HookBus(), + onAfterEventCreate: new HookBus(), + onBeforeEventUpdate: new HookBus(), + onAfterEventUpdate: new HookBus(), + onBeforeEventDelete: new HookBus(), + onAfterEventDelete: new HookBus(), + onEventRsvp: new HookBus(), + onEventsImport: new HookBus(), + onCalendarDateChange: new HookBus(), + onCalendarViewChange: new HookBus(), + onCalendarChange: new HookBus(), + onCalendarVisibilityToggle: new HookBus(), + onICalSubscriptionChange: new HookBus(), + onCalendarAlert: new HookBus(), + onCalendarAlertAcknowledge: new HookBus(), +}; + +// §7.3 Contact Hooks +export const contactHooks = { + onContactOpen: new HookBus(), + onBeforeContactCreate: new HookBus(), + onAfterContactCreate: new HookBus(), + onBeforeContactUpdate: new HookBus(), + onAfterContactUpdate: new HookBus(), + onBeforeContactDelete: new HookBus(), + onAfterContactDelete: new HookBus(), + onContactsImport: new HookBus(), + onContactSelectionChange: new HookBus(), + onContactGroupChange: new HookBus(), + onContactGroupMemberChange: new HookBus(), + onContactMove: new HookBus(), +}; + +// §7.4 File Hooks +export const fileHooks = { + onFileNavigate: new HookBus(), + onBeforeFileUpload: new HookBus(), + onAfterFileUpload: new HookBus(), + onFileDownload: new HookBus(), + onFileUploadCancel: new HookBus(), + onDirectoryCreate: new HookBus(), + onBeforeFileDelete: new HookBus(), + onAfterFileDelete: new HookBus(), + onFileRename: new HookBus(), + onFileMove: new HookBus(), + onFileCopy: new HookBus(), + onFileDuplicate: new HookBus(), + onFileFavoriteToggle: new HookBus(), + onFileSelectionChange: new HookBus(), + onFileUndo: new HookBus(), +}; + +// §7.5 Auth Hooks +export const authHooks = { + onLogin: new HookBus(), + onBeforeLogout: new HookBus(), + onAfterLogout: new HookBus(), + onAccountSwitch: new HookBus(), + onAccountAdd: new HookBus(), + onAccountRemove: new HookBus(), + onTokenRefresh: new HookBus(), + onAuthReady: new HookBus(), +}; + +// §7.6 Settings Hooks +export const settingsHooks = { + onSettingChange: new HookBus(), + onSettingsExport: new HookBus(), + onSettingsImport: new HookBus(), + onSettingsReset: new HookBus(), + onSettingsSync: new HookBus(), + onKeywordChange: new HookBus(), + onTrustedSenderChange: new HookBus(), +}; + +// §7.7 Identity Hooks +export const identityHooks = { + onIdentitiesLoaded: new HookBus(), + onIdentityCreate: new HookBus(), + onIdentityUpdate: new HookBus(), + onIdentityDelete: new HookBus(), + onIdentitySelect: new HookBus(), + onSignatureRender: new HookBus(), +}; + +// §7.8 Filter Hooks +export const filterHooks = { + onFiltersLoaded: new HookBus(), + onFilterRuleChange: new HookBus(), + onFiltersSave: new HookBus(), + onSieveScriptChange: new HookBus(), +}; + +// §7.9 Task Hooks +export const taskHooks = { + onTasksLoaded: new HookBus(), + onTaskCreate: new HookBus(), + onTaskUpdate: new HookBus(), + onTaskDelete: new HookBus(), + onTaskToggleComplete: new HookBus(), + onTaskFilterChange: new HookBus(), +}; + +// §7.10 Template Hooks +export const templateHooks = { + onTemplateCreate: new HookBus(), + onTemplateUpdate: new HookBus(), + onTemplateDelete: new HookBus(), + onTemplateApply: new HookBus(), + onTemplatesImport: new HookBus(), + onTemplateRender: new HookBus(), +}; + +// §7.11 S/MIME Hooks +export const smimeHooks = { + onSmimeKeyImport: new HookBus(), + onSmimeCertImport: new HookBus(), + onSmimeKeyStateChange: new HookBus(), + onSmimeDefaultsChange: new HookBus(), +}; + +// §7.12 Vacation Hooks +export const vacationHooks = { + onVacationLoaded: new HookBus(), + onVacationUpdate: new HookBus(), +}; + +// §7.13 UI Hooks +export const uiHooks = { + onViewChange: new HookBus(), + onSidebarToggle: new HookBus(), + onSidebarCollapse: new HookBus(), + onDeviceTypeChange: new HookBus(), + onColumnResize: new HookBus(), + onMobileBack: new HookBus(), + onMobileViewSwitch: new HookBus(), +}; + +// §7.14 Theme Hooks +export const themeHooks = { + onThemeChange: new HookBus(), + onCustomThemeChange: new HookBus(), + onLocaleChange: new HookBus(), +}; + +// §7.15 Toast Hooks +export const toastHooks = { + onToastShow: new HookBus(), + onToastDismiss: new HookBus(), + onBrowserNotification: new HookBus(), +}; + +// §7.16 Drag & Drop Hooks +export const dragDropHooks = { + onDragStart: new HookBus(), + onDragEnd: new HookBus(), + onEmailDrop: new HookBus(), + onTagDrop: new HookBus(), +}; + +// §7.17 Keyboard Hooks +export const keyboardHooks = { + registerShortcut: new HookBus(), + onBeforeShortcut: new HookBus(), + onAfterShortcut: new HookBus(), +}; + +// §7.18 App Lifecycle Hooks +export const appLifecycleHooks = { + onAppReady: new HookBus(), + onVisibilityChange: new HookBus(), + onBeforeUnload: new HookBus(), + onAppError: new HookBus(), + onInterval: new HookBus(), +}; + +// §7.19 Account Security Hooks +export const accountSecurityHooks = { + onPasswordChange: new HookBus(), + onTotpChange: new HookBus(), + onAppPasswordChange: new HookBus(), + onEncryptionChange: new HookBus(), + onDisplayNameChange: new HookBus(), +}; + +// §7.20 Sidebar App Hooks +export const sidebarAppHooks = { + onSidebarAppOpen: new HookBus(), + onSidebarAppClose: new HookBus(), + onSidebarAppChange: new HookBus(), +}; + +// ─── Aggregate: remove all handlers for a plugin across all buses ─── + +const allHookGroups = [ + emailHooks, calendarHooks, contactHooks, fileHooks, + authHooks, settingsHooks, identityHooks, filterHooks, + taskHooks, templateHooks, smimeHooks, vacationHooks, + uiHooks, themeHooks, toastHooks, dragDropHooks, + keyboardHooks, appLifecycleHooks, accountSecurityHooks, sidebarAppHooks, +]; + +export function removeAllPluginHooks(pluginId: string): void { + for (const group of allHookGroups) { + for (const bus of Object.values(group)) { + (bus as HookBus).removePlugin(pluginId); + } + } +} + +export function clearAllHooks(): void { + for (const group of allHookGroups) { + for (const bus of Object.values(group)) { + (bus as HookBus).clear(); + } + } +} diff --git a/lib/plugin-loader.ts b/lib/plugin-loader.ts new file mode 100644 index 00000000..98873026 --- /dev/null +++ b/lib/plugin-loader.ts @@ -0,0 +1,160 @@ +// Plugin Loader — loads and activates plugins via blob URL dynamic import + +import type { InstalledPlugin, Disposable } from './plugin-types'; +import { pluginStorage } from './plugin-storage'; +import { createPluginAPI, type PluginAPI } from './plugin-api'; +import { removeAllPluginHooks, pluginErrorTracker } from './plugin-hooks'; +import React from 'react'; +import ReactDOM from 'react-dom'; +import * as ReactJSX from 'react/jsx-runtime'; + +// ─── Shared React (window.__PLUGIN_EXTERNALS__) ───────────── + +export function exposePluginExternals(): void { + if (typeof window === 'undefined') return; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (globalThis as any).__PLUGIN_EXTERNALS__ = { + React, + ReactDOM, + ReactJSX, + }; +} + +// ─── Active plugin tracking ────────────────────────────────── + +interface ActivePlugin { + id: string; + api: PluginAPI; + disposable?: Disposable; + deactivate?: () => void; +} + +const activePlugins = new Map(); + +// ─── Load a single plugin ──────────────────────────────────── + +type PluginStoreAccessor = { + setPluginStatus: (id: string, status: InstalledPlugin['status'], error?: string) => void; +}; + +let storeAccessor: PluginStoreAccessor | null = null; + +export function setPluginStoreAccessor(accessor: PluginStoreAccessor): void { + storeAccessor = accessor; +} + +export async function loadPlugin(plugin: InstalledPlugin): Promise { + if (activePlugins.has(plugin.id)) { + console.warn(`[plugin-loader] Plugin "${plugin.id}" is already loaded`); + return; + } + + try { + // 1. Read bundle from IndexedDB + const code = await pluginStorage.getCode(plugin.id); + if (!code) { + throw new Error(`No code found in storage for plugin "${plugin.id}"`); + } + + // 2. Create scoped module via blob URL + const blob = new Blob([code], { type: 'application/javascript' }); + const url = URL.createObjectURL(blob); + + // 3. Dynamic import (webpackIgnore prevents bundler processing) + let mod: { activate?: (api: PluginAPI) => void | Disposable; deactivate?: () => void }; + try { + mod = await import(/* webpackIgnore: true */ url); + } finally { + URL.revokeObjectURL(url); + } + + if (typeof mod.activate !== 'function') { + throw new Error(`Plugin "${plugin.id}" has no activate() export`); + } + + // 4. Build sandboxed API + const api = createPluginAPI(plugin); + + // 5. Call activate + const disposable = await mod.activate(api); + + // 6. Track active plugin + activePlugins.set(plugin.id, { + id: plugin.id, + api, + disposable: disposable && typeof disposable === 'object' && 'dispose' in disposable + ? disposable as Disposable + : undefined, + deactivate: mod.deactivate, + }); + + // 7. Mark running + storeAccessor?.setPluginStatus(plugin.id, 'running'); + console.info(`[plugin-loader] Plugin "${plugin.id}" activated`); + } catch (err) { + const errorMsg = err instanceof Error ? err.message : String(err); + storeAccessor?.setPluginStatus(plugin.id, 'error', errorMsg); + console.error(`[plugin-loader] Plugin "${plugin.id}" failed to load:`, err); + } +} + +// ─── Deactivate a single plugin ────────────────────────────── + +export function deactivatePlugin(pluginId: string): void { + const active = activePlugins.get(pluginId); + if (!active) return; + + try { + // Call deactivate() if provided + active.deactivate?.(); + // Dispose the disposable returned from activate() + active.disposable?.dispose(); + } catch (err) { + console.error(`[plugin-loader] Error deactivating plugin "${pluginId}":`, err); + } + + // Remove all hook subscriptions for this plugin + removeAllPluginHooks(pluginId); + + // Reset error tracker + pluginErrorTracker.reset(pluginId); + + activePlugins.delete(pluginId); + storeAccessor?.setPluginStatus(pluginId, 'disabled'); + console.info(`[plugin-loader] Plugin "${pluginId}" deactivated`); +} + +// ─── Activate all enabled plugins ──────────────────────────── + +export async function activateAllPlugins(plugins: InstalledPlugin[]): Promise { + // Ensure externals are exposed + exposePluginExternals(); + + const enabledPlugins = plugins.filter(p => p.enabled && p.status !== 'error'); + for (const plugin of enabledPlugins) { + await loadPlugin(plugin); + } +} + +// ─── Deactivate all plugins ───────────────────────────────── + +export function deactivateAllPlugins(): void { + for (const pluginId of [...activePlugins.keys()]) { + deactivatePlugin(pluginId); + } +} + +// ─── Check if a plugin is active ───────────────────────────── + +export function isPluginActive(pluginId: string): boolean { + return activePlugins.has(pluginId); +} + +// ─── Setup auto-disable callback ───────────────────────────── + +export function setupAutoDisable(): void { + pluginErrorTracker.setAutoDisableCallback((pluginId) => { + deactivatePlugin(pluginId); + storeAccessor?.setPluginStatus(pluginId, 'error', 'Auto-disabled due to repeated errors'); + }); +} diff --git a/lib/plugin-storage.ts b/lib/plugin-storage.ts new file mode 100644 index 00000000..80a700c0 --- /dev/null +++ b/lib/plugin-storage.ts @@ -0,0 +1,96 @@ +// IndexedDB storage for plugin/theme binary blobs (JS bundles, CSS, previews) + +const DB_NAME = 'bulwark-plugins'; +const DB_VERSION = 1; +const STORE_PLUGINS = 'plugin-code'; +const STORE_THEMES = 'theme-css'; +const STORE_PREVIEWS = 'previews'; + +function openDB(): Promise { + return new Promise((resolve, reject) => { + const request = indexedDB.open(DB_NAME, DB_VERSION); + + request.onupgradeneeded = () => { + const db = request.result; + if (!db.objectStoreNames.contains(STORE_PLUGINS)) { + db.createObjectStore(STORE_PLUGINS); + } + if (!db.objectStoreNames.contains(STORE_THEMES)) { + db.createObjectStore(STORE_THEMES); + } + if (!db.objectStoreNames.contains(STORE_PREVIEWS)) { + db.createObjectStore(STORE_PREVIEWS); + } + }; + + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error); + }); +} + +async function putItem(storeName: string, key: string, value: string | Blob): Promise { + const db = await openDB(); + return new Promise((resolve, reject) => { + const tx = db.transaction(storeName, 'readwrite'); + tx.objectStore(storeName).put(value, key); + tx.oncomplete = () => resolve(); + tx.onerror = () => reject(tx.error); + }); +} + +async function getItem(storeName: string, key: string): Promise { + const db = await openDB(); + return new Promise((resolve, reject) => { + const tx = db.transaction(storeName, 'readonly'); + const request = tx.objectStore(storeName).get(key); + request.onsuccess = () => resolve(request.result ?? null); + request.onerror = () => reject(request.error); + }); +} + +async function deleteItem(storeName: string, key: string): Promise { + const db = await openDB(); + return new Promise((resolve, reject) => { + const tx = db.transaction(storeName, 'readwrite'); + tx.objectStore(storeName).delete(key); + tx.oncomplete = () => resolve(); + tx.onerror = () => reject(tx.error); + }); +} + +// ─── Public API ────────────────────────────────────────────── + +export const pluginStorage = { + // Plugin JS bundles + async saveCode(pluginId: string, code: string): Promise { + await putItem(STORE_PLUGINS, pluginId, code); + }, + async getCode(pluginId: string): Promise { + return getItem(STORE_PLUGINS, pluginId); + }, + async deleteCode(pluginId: string): Promise { + await deleteItem(STORE_PLUGINS, pluginId); + }, + + // Theme CSS blobs + async saveThemeCSS(themeId: string, css: string): Promise { + await putItem(STORE_THEMES, themeId, css); + }, + async getThemeCSS(themeId: string): Promise { + return getItem(STORE_THEMES, themeId); + }, + async deleteThemeCSS(themeId: string): Promise { + await deleteItem(STORE_THEMES, themeId); + }, + + // Preview images (stored as data URIs) + async savePreview(id: string, dataUri: string): Promise { + await putItem(STORE_PREVIEWS, id, dataUri); + }, + async getPreview(id: string): Promise { + return getItem(STORE_PREVIEWS, id); + }, + async deletePreview(id: string): Promise { + await deleteItem(STORE_PREVIEWS, id); + }, +}; diff --git a/lib/plugin-types.ts b/lib/plugin-types.ts new file mode 100644 index 00000000..d679f898 --- /dev/null +++ b/lib/plugin-types.ts @@ -0,0 +1,397 @@ +// Plugin & Theme system types + +// ─── Common ────────────────────────────────────────────────── + +export type Disposable = { dispose: () => void }; +export type MaybePromise = T | Promise; + +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; + permissions: string[]; + entrypoint: string; + minAppVersion?: string; + settingsSchema?: Record; +} + +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; + permissions: string[]; + entrypoint: string; + enabled: boolean; + status: PluginStatus; + error?: string; + settingsSchema?: Record; + settings: Record; +} + +// ─── 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>; + 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, +]; diff --git a/lib/plugin-validator.ts b/lib/plugin-validator.ts new file mode 100644 index 00000000..fe71a5e6 --- /dev/null +++ b/lib/plugin-validator.ts @@ -0,0 +1,326 @@ +// Plugin/Theme ZIP upload validation, extraction, and manifest parsing + +import JSZip from 'jszip'; +import { + type ThemeManifest, + type PluginManifest, + type PluginType, + ALL_PERMISSIONS, + MAX_PLUGIN_SIZE, + MAX_THEME_SIZE, + ALLOWED_PLUGIN_FILES, +} from './plugin-types'; +import { sanitizeThemeCSS, validateThemeCSSSafety } from './theme-loader'; + +export interface ValidationResult { + valid: boolean; + errors: string[]; + warnings: string[]; +} + +export interface ThemeExtractionResult extends ValidationResult { + manifest: ThemeManifest | null; + css: string; + preview: string | null; // data URI +} + +export interface PluginExtractionResult extends ValidationResult { + manifest: PluginManifest | null; + code: string; + preview: string | null; +} + +// ─── Manifest Validation ───────────────────────────────────── + +function validateBaseManifest(manifest: Record): string[] { + const errors: string[] = []; + + if (!manifest.id || typeof manifest.id !== 'string') errors.push('Missing or invalid "id"'); + if (!manifest.name || typeof manifest.name !== 'string') errors.push('Missing or invalid "name"'); + if (!manifest.version || typeof manifest.version !== 'string') errors.push('Missing or invalid "version"'); + if (!manifest.author || typeof manifest.author !== 'string') errors.push('Missing or invalid "author"'); + if (!manifest.type || typeof manifest.type !== 'string') errors.push('Missing or invalid "type"'); + + // Validate ID format (alphanumeric + hyphens) + if (manifest.id && typeof manifest.id === 'string' && !/^[a-z0-9][a-z0-9-]*[a-z0-9]$/.test(manifest.id)) { + errors.push('ID must be lowercase alphanumeric with hyphens, min 2 chars'); + } + + return errors; +} + +function validateThemeManifest(manifest: Record): { result: ThemeManifest | null; errors: string[] } { + const errors = validateBaseManifest(manifest); + + if (manifest.type !== 'theme') { + errors.push(`Expected type "theme", got "${manifest.type}"`); + } + + if (!manifest.variants || !Array.isArray(manifest.variants) || manifest.variants.length === 0) { + errors.push('Missing or empty "variants" array (must be ["light"], ["dark"], or ["light","dark"])'); + } else { + const valid = manifest.variants.every((v: unknown) => v === 'light' || v === 'dark'); + if (!valid) errors.push('Variants must be "light" or "dark"'); + } + + if (errors.length > 0) return { result: null, errors }; + + return { + result: manifest as unknown as ThemeManifest, + errors: [], + }; +} + +function validatePluginManifest(manifest: Record): { result: PluginManifest | null; errors: string[] } { + const errors = validateBaseManifest(manifest); + + const validTypes: PluginType[] = ['ui-extension', 'sidebar-app', 'hook']; + if (!validTypes.includes(manifest.type as PluginType)) { + errors.push(`Invalid type "${manifest.type}". Must be one of: ${validTypes.join(', ')}`); + } + + if (!manifest.entrypoint || typeof manifest.entrypoint !== 'string') { + errors.push('Missing or invalid "entrypoint"'); + } + + if (manifest.permissions && Array.isArray(manifest.permissions)) { + const validPerms = new Set(ALL_PERMISSIONS as readonly string[]); + const unknown = (manifest.permissions as string[]).filter(p => !validPerms.has(p)); + if (unknown.length > 0) { + errors.push(`Unknown permissions: ${unknown.join(', ')}`); + } + } + + if (errors.length > 0) return { result: null, errors }; + + return { + result: { + ...(manifest as unknown as PluginManifest), + permissions: (manifest.permissions as string[]) || [], + }, + errors: [], + }; +} + +// ─── JS Security Checks ───────────────────────────────────── + +const SUSPICIOUS_JS_PATTERNS = [ + { pattern: /\beval\s*\(/g, label: 'eval()' }, + { pattern: /\bnew\s+Function\s*\(/g, label: 'new Function()' }, + { pattern: /document\.cookie/g, label: 'document.cookie' }, + { pattern: /document\.write/g, label: 'document.write' }, + { pattern: /innerHTML\s*=/g, label: 'innerHTML assignment' }, +]; + +function checkJSSecurity(code: string): string[] { + const warnings: string[] = []; + for (const { pattern, label } of SUSPICIOUS_JS_PATTERNS) { + if (pattern.test(code)) { + warnings.push(`Contains ${label} — review for security`); + } + pattern.lastIndex = 0; // reset regex + } + return warnings; +} + +// ─── ZIP Extraction ────────────────────────────────────────── + +function getExtension(filename: string): string { + const dot = filename.lastIndexOf('.'); + return dot >= 0 ? filename.slice(dot).toLowerCase() : ''; +} + +/** + * Find the root of the ZIP contents. + * Some ZIPs have all files inside a single top-level folder. + */ +function findZipRoot(zip: JSZip): string { + const entries = Object.keys(zip.files); + // Check if all entries share a common top-level directory + const topDirs = new Set(entries.map(e => e.split('/')[0])); + if (topDirs.size === 1) { + const dir = [...topDirs][0]; + // Verify it's actually a directory (has entries inside it) + if (zip.files[dir + '/'] || entries.some(e => e.startsWith(dir + '/'))) { + return dir + '/'; + } + } + return ''; +} + +/** + * Extract and validate a theme ZIP file. + */ +export async function extractTheme(file: File): Promise { + const errors: string[] = []; + const warnings: string[] = []; + + // Size check + if (file.size > MAX_THEME_SIZE) { + return { valid: false, errors: ['Theme ZIP exceeds 1 MB size limit'], warnings: [], manifest: null, css: '', preview: null }; + } + + let zip: JSZip; + try { + const buffer = await file.arrayBuffer(); + zip = await JSZip.loadAsync(buffer); + } catch { + return { valid: false, errors: ['Invalid ZIP file'], warnings: [], manifest: null, css: '', preview: null }; + } + + const root = findZipRoot(zip); + + // Read manifest + const manifestFile = zip.file(root + 'manifest.json'); + if (!manifestFile) { + return { valid: false, errors: ['Missing manifest.json'], warnings: [], manifest: null, css: '', preview: null }; + } + + let manifestData: Record; + try { + const raw = await manifestFile.async('string'); + manifestData = JSON.parse(raw); + } catch { + return { valid: false, errors: ['Invalid manifest.json (not valid JSON)'], warnings: [], manifest: null, css: '', preview: null }; + } + + const { result: manifest, errors: manifestErrors } = validateThemeManifest(manifestData); + errors.push(...manifestErrors); + if (!manifest) { + return { valid: false, errors, warnings, manifest: null, css: '', preview: null }; + } + + // Read theme.css + const cssFile = zip.file(root + 'theme.css'); + if (!cssFile) { + errors.push('Missing theme.css'); + return { valid: false, errors, warnings, manifest, css: '', preview: null }; + } + + let rawCSS = await cssFile.async('string'); + + // Validate CSS safety + const safety = validateThemeCSSSafety(rawCSS); + if (!safety.valid) { + // Sanitize instead of rejecting + const sanitized = sanitizeThemeCSS(rawCSS); + rawCSS = sanitized.css; + warnings.push(...sanitized.warnings); + } + + // Read preview image if present + let preview: string | null = null; + if (manifest.preview) { + const previewFile = zip.file(root + manifest.preview); + if (previewFile) { + try { + const blob = await previewFile.async('blob'); + preview = await blobToDataUri(blob); + } catch { + warnings.push('Could not read preview image'); + } + } + } + + return { + valid: errors.length === 0, + errors, + warnings, + manifest, + css: rawCSS, + preview, + }; +} + +/** + * Extract and validate a plugin ZIP file. + */ +export async function extractPlugin(file: File): Promise { + const errors: string[] = []; + const warnings: string[] = []; + + if (file.size > MAX_PLUGIN_SIZE) { + return { valid: false, errors: ['Plugin ZIP exceeds 5 MB size limit'], warnings: [], manifest: null, code: '', preview: null }; + } + + let zip: JSZip; + try { + const buffer = await file.arrayBuffer(); + zip = await JSZip.loadAsync(buffer); + } catch { + return { valid: false, errors: ['Invalid ZIP file'], warnings: [], manifest: null, code: '', preview: null }; + } + + const root = findZipRoot(zip); + + // Check for disallowed file extensions + for (const [path, entry] of Object.entries(zip.files)) { + if (entry.dir) continue; + const ext = getExtension(path); + if (ext && !ALLOWED_PLUGIN_FILES.has(ext)) { + errors.push(`Disallowed file type: ${path} (${ext})`); + } + } + + // Read manifest + const manifestFile = zip.file(root + 'manifest.json'); + if (!manifestFile) { + return { valid: false, errors: ['Missing manifest.json', ...errors], warnings, manifest: null, code: '', preview: null }; + } + + let manifestData: Record; + try { + const raw = await manifestFile.async('string'); + manifestData = JSON.parse(raw); + } catch { + return { valid: false, errors: ['Invalid manifest.json (not valid JSON)', ...errors], warnings, manifest: null, code: '', preview: null }; + } + + const { result: manifest, errors: manifestErrors } = validatePluginManifest(manifestData); + errors.push(...manifestErrors); + if (!manifest) { + return { valid: false, errors, warnings, manifest: null, code: '', preview: null }; + } + + // Read entrypoint + const entryFile = zip.file(root + manifest.entrypoint); + if (!entryFile) { + errors.push(`Missing entrypoint file: ${manifest.entrypoint}`); + return { valid: false, errors, warnings, manifest, code: '', preview: null }; + } + + const code = await entryFile.async('string'); + + // JS security checks + warnings.push(...checkJSSecurity(code)); + + // Read preview if present + let preview: string | null = null; + const previewFile = zip.file(root + 'preview.png') || zip.file(root + 'preview.svg'); + if (previewFile) { + try { + const blob = await previewFile.async('blob'); + preview = await blobToDataUri(blob); + } catch { + warnings.push('Could not read preview image'); + } + } + + return { + valid: errors.length === 0, + errors, + warnings, + manifest, + code, + preview, + }; +} + +function blobToDataUri(blob: Blob): Promise { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(reader.result as string); + reader.onerror = () => reject(reader.error); + reader.readAsDataURL(blob); + }); +} diff --git a/lib/theme-loader.ts b/lib/theme-loader.ts new file mode 100644 index 00000000..12d7a9b5 --- /dev/null +++ b/lib/theme-loader.ts @@ -0,0 +1,115 @@ +// Theme CSS injection and sanitization + +import { DISALLOWED_CSS_PATTERNS } from './plugin-types'; + +const THEME_STYLE_ID = 'active-theme'; + +/** + * Sanitize theme CSS: strip dangerous patterns like @import, external url(), + * JavaScript expressions, and -moz-binding. Returns cleaned CSS. + */ +export function sanitizeThemeCSS(css: string): { css: string; warnings: string[] } { + const warnings: string[] = []; + let cleaned = css; + + for (const pattern of DISALLOWED_CSS_PATTERNS) { + if (pattern.test(cleaned)) { + warnings.push(`Removed disallowed pattern: ${pattern.source}`); + cleaned = cleaned.replace(new RegExp(pattern.source, 'gi'), '/* [removed] */'); + } + } + + return { css: cleaned, warnings }; +} + +/** + * Validate that theme CSS only targets :root and .dark selectors. + * Returns warnings for any other selectors found. + */ +export function validateThemeSelectors(css: string): string[] { + const warnings: string[] = []; + + // Remove comments + const noComments = css.replace(/\/\*[\s\S]*?\*\//g, ''); + + // Find selector blocks (text before { that isn't inside a value) + const selectorRegex = /([^{}]+)\{/g; + let match; + while ((match = selectorRegex.exec(noComments)) !== null) { + const selector = match[1].trim(); + // Allow :root, .dark, @font-face, @keyframes, @media + if ( + selector === ':root' || + selector === '.dark' || + selector.startsWith('@font-face') || + selector.startsWith('@keyframes') || + selector.startsWith('@media') || + selector === '' + ) { + continue; + } + + // Inside @media blocks, also allow :root and .dark + if (selector === ':root' || selector === '.dark') continue; + + warnings.push(`Non-standard selector "${selector}" — themes should only use :root and .dark`); + } + + return warnings; +} + +/** + * Inject theme CSS into the document head. + * Inserted after globals.css so theme variables win specificity. + */ +export function injectThemeCSS(css: string): void { + if (typeof document === 'undefined') return; + + let styleEl = document.getElementById(THEME_STYLE_ID) as HTMLStyleElement | null; + + if (!styleEl) { + styleEl = document.createElement('style'); + styleEl.id = THEME_STYLE_ID; + document.head.appendChild(styleEl); + } + + styleEl.textContent = css; +} + +/** + * Remove injected theme CSS, reverting to default. + */ +export function removeThemeCSS(): void { + if (typeof document === 'undefined') return; + + const styleEl = document.getElementById(THEME_STYLE_ID); + if (styleEl) { + styleEl.remove(); + } +} + +/** + * Check if a theme CSS string is valid and safe. + */ +export function validateThemeCSSSafety(css: string): { valid: boolean; errors: string[] } { + const errors: string[] = []; + + if (!css.trim()) { + errors.push('Theme CSS is empty'); + return { valid: false, errors }; + } + + // Check for dangerous patterns + for (const pattern of DISALLOWED_CSS_PATTERNS) { + if (pattern.test(css)) { + errors.push(`Contains disallowed pattern: ${pattern.source}`); + } + } + + // Check the CSS actually sets some variables + if (!css.includes('--color-')) { + errors.push('Theme CSS should set at least one --color-* variable'); + } + + return { valid: errors.length === 0, errors }; +} diff --git a/package-lock.json b/package-lock.json index 903c4a35..b707cb4c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "bulwark-webmail", - "version": "1.4.6", + "version": "1.4.8", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "bulwark-webmail", - "version": "1.4.6", + "version": "1.4.8", "license": "AGPL-3.0-only", "dependencies": { "@tanstack/react-virtual": "^3.13.18", @@ -24,6 +24,7 @@ "clsx": "^2.1.1", "date-fns": "^4.1.0", "dompurify": "^3.3.3", + "jszip": "^3.10.1", "lucide-react": "^0.575.0", "next": "^16.1.5", "next-intl": "^4.5.8", @@ -5035,6 +5036,12 @@ "url": "https://opencollective.com/core-js" } }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "license": "MIT" + }, "node_modules/crelt": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz", @@ -6493,6 +6500,12 @@ "node": ">= 4" } }, + "node_modules/immediate": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", + "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==", + "license": "MIT" + }, "node_modules/import-fresh": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", @@ -7116,6 +7129,18 @@ "node": ">=4.0" } }, + "node_modules/jszip": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz", + "integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==", + "license": "(MIT OR GPL-3.0-or-later)", + "dependencies": { + "lie": "~3.3.0", + "pako": "~1.0.2", + "readable-stream": "~2.3.6", + "setimmediate": "^1.0.5" + } + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -7140,6 +7165,15 @@ "node": ">= 0.8.0" } }, + "node_modules/lie": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz", + "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==", + "license": "MIT", + "dependencies": { + "immediate": "~3.0.5" + } + }, "node_modules/lightningcss": { "version": "1.31.1", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.31.1.tgz", @@ -8035,6 +8069,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/pako": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", + "license": "(MIT AND Zlib)" + }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -8251,6 +8291,12 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "license": "MIT" + }, "node_modules/prop-types": { "version": "15.8.1", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", @@ -8540,6 +8586,27 @@ "node": ">=0.10.0" } }, + "node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/readable-stream/node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "license": "MIT" + }, "node_modules/redent": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", @@ -8689,6 +8756,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, "node_modules/safe-push-apply": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz", @@ -8805,6 +8878,12 @@ "node": ">= 0.4" } }, + "node_modules/setimmediate": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", + "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==", + "license": "MIT" + }, "node_modules/sharp": { "version": "0.34.5", "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", @@ -9018,6 +9097,15 @@ "node": ">= 0.4" } }, + "node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, "node_modules/string.prototype.matchall": { "version": "4.0.12", "resolved": "https://registry.npmjs.org/string.prototype.matchall/-/string.prototype.matchall-4.0.12.tgz", @@ -9597,6 +9685,12 @@ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, "node_modules/vite": { "version": "7.3.1", "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", diff --git a/package.json b/package.json index a849850b..193c0437 100644 --- a/package.json +++ b/package.json @@ -47,6 +47,7 @@ "clsx": "^2.1.1", "date-fns": "^4.1.0", "dompurify": "^3.3.3", + "jszip": "^3.10.1", "lucide-react": "^0.575.0", "next": "^16.1.5", "next-intl": "^4.5.8", diff --git a/proxy.ts b/proxy.ts index dc44a1cc..bbf5c4f9 100644 --- a/proxy.ts +++ b/proxy.ts @@ -30,11 +30,17 @@ export function proxy(request: NextRequest) { `frame-ancestors ${frameAncestors}`, ].join("; "); + // Skip intl middleware for /admin routes — they have their own layout + const pathname = request.nextUrl.pathname; + const isAdminRoute = pathname === '/admin' || pathname.startsWith('/admin/'); + let intlResponse: ReturnType | null = null; - try { - intlResponse = intlMiddleware(request); - } catch (error) { - console.error('Locale middleware error:', error); + if (!isAdminRoute) { + try { + intlResponse = intlMiddleware(request); + } catch (error) { + console.error('Locale middleware error:', error); + } } const response = intlResponse ?? NextResponse.next(); diff --git a/stores/plugin-store.ts b/stores/plugin-store.ts new file mode 100644 index 00000000..86bd4e60 --- /dev/null +++ b/stores/plugin-store.ts @@ -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 { + const slots = {} as Record; + for (const name of SLOT_NAMES) { + slots[name] = []; + } + return slots; +} + +// ─── Store Interface ───────────────────────────────────────── + +interface PluginStoreState { + plugins: InstalledPlugin[]; + slots: Record; + initialized: boolean; + + // Management + installPlugin: (file: File) => Promise<{ success: boolean; error?: string; warnings?: string[] }>; + uninstallPlugin: (id: string) => void; + enablePlugin: (id: string) => Promise; + disablePlugin: (id: string) => void; + updatePluginSettings: (id: string, settings: Record) => 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; +} + +// ─── Store ─────────────────────────────────────────────────── + +export const usePluginStore = create()( + 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) => { + 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; + } + }; + }, + } + ) +); diff --git a/stores/policy-store.ts b/stores/policy-store.ts new file mode 100644 index 00000000..6397d0f8 --- /dev/null +++ b/stores/policy-store.ts @@ -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; + 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()((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]; + }, +})); diff --git a/stores/theme-store.ts b/stores/theme-store.ts index 6588208b..e1985632 100644 --- a/stores/theme-store.ts +++ b/stores/theme-store.ts @@ -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()( 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()( }, 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()( 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()( 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()( }, } ) -); \ No newline at end of file +); + +/** 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); +} \ No newline at end of file