feat: add plugin/theme harness and admin dashboard
Plugin & Theme System: - Add plugin type definitions, permissions (30+), and validation constants - Add IndexedDB storage layer for plugin code, theme CSS, and previews - Add theme CSS sanitization, injection, and safety validation - Add HookBus event system with 130+ hooks across 20 domains - Add plugin ZIP extraction and manifest validation with JS security checks - Add sandboxed PluginAPI factory with scoped storage, logging, and permission gating - Add plugin loader with blob URL dynamic import and auto-disable circuit breaker - Add 3 built-in themes (Nord, Catppuccin, Solarized) - Add Zustand plugin store with install/uninstall/enable/disable lifecycle - Add PluginSlot, PluginSlotRenderer, and PluginErrorBoundary components - Add plugins and themes settings UI panels - Integrate plugin slots into email viewer, composer, navigation rail, sidebar, and context menu - Extend theme store with custom theme installation and activation Admin Dashboard: - Add admin authentication with scrypt password hashing and AES-256-GCM sessions - Add rate-limited login (5 attempts/15min per IP) - Add config manager with admin override > env var > default priority - Add settings policy system with feature gates and per-setting restrictions - Add audit logging with rotation - Add admin API routes (login, logout, config, policy, audit, password change) - Add admin UI pages (login, dashboard, config, policy, audit) - Add policy store for client-side feature gate enforcement - Wire admin password initialization into server instrumentation Tests: - Add 139 tests across 10 test files covering all plugin/theme modules
This commit is contained in:
@@ -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<Tab, LucideIcon> = {
|
||||
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<Tab>(() => {
|
||||
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' && <KeywordSettings />}
|
||||
{activeTab === 'files' && <FilesSettingsComponent />}
|
||||
{activeTab === 'sidebar_apps' && <SidebarAppsSettings />}
|
||||
{activeTab === 'themes' && <ThemesSettings />}
|
||||
{activeTab === 'plugins' && <PluginsSettings />}
|
||||
{activeTab === 'advanced' && <AdvancedSettings />}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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<Record<string, ConfigEntry>>({});
|
||||
const [edits, setEdits] = useState<Record<string, unknown>>({});
|
||||
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 <div className="flex items-center justify-center py-12 text-muted-foreground text-sm">Loading...</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold text-foreground">Authentication</h1>
|
||||
<p className="text-sm text-muted-foreground mt-1">OAuth, SSO, and session configuration</p>
|
||||
</div>
|
||||
{hasEdits && (
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
className="inline-flex items-center gap-2 h-9 px-4 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 disabled:opacity-50 transition-all shadow-sm"
|
||||
>
|
||||
{saving ? <Loader2 className="w-4 h-4 animate-spin" /> : <Save className="w-4 h-4" />}
|
||||
Save changes
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{message && (
|
||||
<div className={`text-sm rounded-md px-3 py-2 ${message.type === 'success' ? 'bg-emerald-50 text-emerald-700 dark:bg-emerald-950/30 dark:text-emerald-300' : 'bg-destructive/10 text-destructive'}`}>
|
||||
{message.text}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* OAuth */}
|
||||
<Section title="OAuth / OpenID Connect">
|
||||
<Toggle label="OAuth Enabled" configKey="oauthEnabled" value={currentValue('oauthEnabled') as boolean} source={config.oauthEnabled?.source} onChange={handleChange} onRevert={handleRevert} />
|
||||
<Toggle label="OAuth Only" description="Hide password login form when enabled" configKey="oauthOnly" value={currentValue('oauthOnly') as boolean} source={config.oauthOnly?.source} onChange={handleChange} onRevert={handleRevert} />
|
||||
<Text label="OAuth Client ID" configKey="oauthClientId" value={currentValue('oauthClientId') as string} source={config.oauthClientId?.source} onChange={handleChange} onRevert={handleRevert} />
|
||||
<Text label="OAuth Client Secret" configKey="oauthClientSecret" value={currentValue('oauthClientSecret') as string} source={config.oauthClientSecret?.source} onChange={handleChange} onRevert={handleRevert} type="password" />
|
||||
<Text label="OAuth Issuer URL" configKey="oauthIssuerUrl" value={currentValue('oauthIssuerUrl') as string} source={config.oauthIssuerUrl?.source} onChange={handleChange} onRevert={handleRevert} placeholder="https://auth.example.com" />
|
||||
</Section>
|
||||
|
||||
{/* SSO */}
|
||||
<Section title="Single Sign-On">
|
||||
<Toggle label="Auto SSO" description="Automatically redirect to SSO provider on load" configKey="autoSsoEnabled" value={currentValue('autoSsoEnabled') as boolean} source={config.autoSsoEnabled?.source} onChange={handleChange} onRevert={handleRevert} />
|
||||
</Section>
|
||||
|
||||
{/* Session & Security */}
|
||||
<Section title="Session & Security">
|
||||
<Select label="Cookie SameSite" configKey="cookieSameSite" value={currentValue('cookieSameSite') as string} source={config.cookieSameSite?.source} options={['lax', 'strict', 'none']} onChange={handleChange} onRevert={handleRevert} />
|
||||
<Text label="Allowed Frame Ancestors" configKey="allowedFrameAncestors" value={currentValue('allowedFrameAncestors') as string} source={config.allowedFrameAncestors?.source} onChange={handleChange} onRevert={handleRevert} placeholder="'none' or https://..." />
|
||||
<Text label="Parent Origin" description="For embedded mode communication" configKey="parentOrigin" value={currentValue('parentOrigin') as string} source={config.parentOrigin?.source} onChange={handleChange} onRevert={handleRevert} />
|
||||
</Section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Section({ title, children }: { title: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="border border-border rounded-lg">
|
||||
<div className="px-4 py-3 border-b border-border bg-muted/30">
|
||||
<h2 className="text-sm font-medium text-foreground">{title}</h2>
|
||||
</div>
|
||||
<div className="divide-y divide-border">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SourceBadge({ source }: { source?: string }) {
|
||||
if (!source || source === 'default') return null;
|
||||
return (
|
||||
<span className={`text-[10px] font-medium uppercase tracking-wider px-1.5 py-0.5 rounded ${source === 'admin' ? 'bg-primary/10 text-primary' : 'bg-muted text-muted-foreground'}`}>
|
||||
{source}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function Text({ label, description, configKey, value, source, onChange, onRevert, placeholder, type = 'text' }: {
|
||||
label: string; description?: string; configKey: string; value: string; source?: string;
|
||||
onChange: (k: string, v: unknown) => void; onRevert: (k: string) => void; placeholder?: string; type?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="px-4 py-3 flex items-center justify-between gap-4">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-foreground">{label}</span>
|
||||
<SourceBadge source={source} />
|
||||
</div>
|
||||
{description && <p className="text-xs text-muted-foreground mt-0.5">{description}</p>}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<input type={type} value={value ?? ''} onChange={(e) => 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' && (
|
||||
<button onClick={() => onRevert(configKey)} className="text-muted-foreground hover:text-foreground" title="Revert"><RotateCcw className="w-3.5 h-3.5" /></button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="px-4 py-3 flex items-center justify-between gap-4">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-foreground">{label}</span>
|
||||
<SourceBadge source={source} />
|
||||
</div>
|
||||
{description && <p className="text-xs text-muted-foreground mt-0.5">{description}</p>}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button onClick={() => onChange(configKey, !value)}
|
||||
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${value ? 'bg-primary' : 'bg-muted-foreground/30'}`}>
|
||||
<span className={`inline-block h-3.5 w-3.5 transform rounded-full bg-white shadow transition-transform ${value ? 'translate-x-[18px]' : 'translate-x-[3px]'}`} />
|
||||
</button>
|
||||
{source === 'admin' && (
|
||||
<button onClick={() => onRevert(configKey)} className="text-muted-foreground hover:text-foreground" title="Revert"><RotateCcw className="w-3.5 h-3.5" /></button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="px-4 py-3 flex items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-foreground">{label}</span>
|
||||
<SourceBadge source={source} />
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<select value={value ?? ''} onChange={(e) => onChange(configKey, e.target.value)}
|
||||
className="h-8 rounded-md border border-input bg-background px-2.5 text-sm text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
|
||||
{options.map(o => <option key={o} value={o}>{o}</option>)}
|
||||
</select>
|
||||
{source === 'admin' && (
|
||||
<button onClick={() => onRevert(configKey)} className="text-muted-foreground hover:text-foreground" title="Revert"><RotateCcw className="w-3.5 h-3.5" /></button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<Record<string, ConfigEntry>>({});
|
||||
const [edits, setEdits] = useState<Record<string, unknown>>({});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [uploading, setUploading] = useState<string | null>(null);
|
||||
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
|
||||
const fileInputRefs = useRef<Record<string, HTMLInputElement | 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: 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 <div className="flex items-center justify-center py-12 text-muted-foreground text-sm">Loading...</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold text-foreground">Branding</h1>
|
||||
<p className="text-sm text-muted-foreground mt-1">Customize logos, favicon, and company information</p>
|
||||
</div>
|
||||
{hasEdits && (
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
className="inline-flex items-center gap-2 h-9 px-4 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 disabled:opacity-50 transition-all shadow-sm"
|
||||
>
|
||||
{saving ? <Loader2 className="w-4 h-4 animate-spin" /> : <Save className="w-4 h-4" />}
|
||||
Save changes
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{message && (
|
||||
<div className={`text-sm rounded-md px-3 py-2 ${message.type === 'success' ? 'bg-emerald-50 text-emerald-700 dark:bg-emerald-950/30 dark:text-emerald-300' : 'bg-destructive/10 text-destructive'}`}>
|
||||
{message.text}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="border border-border rounded-lg">
|
||||
<div className="px-4 py-3 border-b border-border bg-muted/30">
|
||||
<h2 className="text-sm font-medium text-foreground">Images & Logos</h2>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">Upload a file or enter a URL. Supported formats: SVG, PNG, JPEG, WebP, ICO (max 2 MB)</p>
|
||||
</div>
|
||||
<div className="divide-y divide-border">
|
||||
{IMAGE_FIELDS.map(field => (
|
||||
<div key={field.key} className="px-4 py-3">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<label className="text-sm text-foreground whitespace-nowrap">{field.label}</label>
|
||||
{config[field.key]?.source === 'admin' && (
|
||||
<span className="text-[10px] font-medium uppercase tracking-wider px-1.5 py-0.5 rounded bg-primary/10 text-primary">
|
||||
{isUploadedFile(field.key) ? 'uploaded' : 'admin'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={currentValue(field.key)}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
<input
|
||||
ref={el => { 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 = '';
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
onClick={() => fileInputRefs.current[field.key]?.click()}
|
||||
disabled={uploading === field.key}
|
||||
className="inline-flex items-center gap-1.5 h-8 px-2.5 rounded-md border border-input bg-background text-sm text-foreground hover:bg-muted disabled:opacity-50 transition-colors"
|
||||
title="Upload file"
|
||||
>
|
||||
{uploading === field.key ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Upload className="w-3.5 h-3.5" />}
|
||||
</button>
|
||||
{isUploadedFile(field.key) && (
|
||||
<button
|
||||
onClick={() => handleDeleteUpload(field.key)}
|
||||
className="text-muted-foreground hover:text-destructive transition-colors"
|
||||
title="Remove uploaded file"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
{config[field.key]?.source === 'admin' && !isUploadedFile(field.key) && (
|
||||
<button onClick={() => handleRevert(field.key)} className="text-muted-foreground hover:text-foreground" title="Revert to default">
|
||||
<RotateCcw className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{/* Preview */}
|
||||
{currentValue(field.key) && (
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<ImageIcon className="w-3.5 h-3.5 text-muted-foreground" />
|
||||
<div className="h-8 w-auto bg-muted rounded flex items-center justify-center px-2">
|
||||
<img
|
||||
src={currentValue(field.key)}
|
||||
alt={field.label}
|
||||
className="max-h-6 max-w-[200px] object-contain"
|
||||
onError={(e) => { (e.target as HTMLImageElement).style.display = 'none'; }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border border-border rounded-lg">
|
||||
<div className="px-4 py-3 border-b border-border bg-muted/30">
|
||||
<h2 className="text-sm font-medium text-foreground">Company Information</h2>
|
||||
</div>
|
||||
<div className="divide-y divide-border">
|
||||
{TEXT_FIELDS.map(field => (
|
||||
<div key={field.key} className="px-4 py-3 flex items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<label className="text-sm text-foreground whitespace-nowrap">{field.label}</label>
|
||||
{config[field.key]?.source === 'admin' && (
|
||||
<span className="text-[10px] font-medium uppercase tracking-wider px-1.5 py-0.5 rounded bg-primary/10 text-primary">admin</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={currentValue(field.key)}
|
||||
onChange={(e) => 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' && (
|
||||
<button onClick={() => handleRevert(field.key)} className="text-muted-foreground hover:text-foreground" title="Revert to default">
|
||||
<RotateCcw className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="max-w-md mx-auto space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold text-foreground">Change Password</h1>
|
||||
<p className="text-sm text-muted-foreground mt-1">Update your admin password.</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-sm font-medium text-foreground">Current Password</label>
|
||||
<div className="relative">
|
||||
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||||
<input
|
||||
type="password"
|
||||
value={currentPassword}
|
||||
onChange={e => 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"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-sm font-medium text-foreground">New Password</label>
|
||||
<input
|
||||
type="password"
|
||||
value={newPassword}
|
||||
onChange={e => 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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-sm font-medium text-foreground">Confirm New Password</label>
|
||||
<input
|
||||
type="password"
|
||||
value={confirmPassword}
|
||||
onChange={e => 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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p className="text-sm text-red-500">{error}</p>
|
||||
)}
|
||||
{success && (
|
||||
<p className="text-sm text-green-600">Password changed. Redirecting...</p>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full h-9 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{loading ? 'Changing...' : 'Change Password'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<boolean | null>(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 (
|
||||
<div className="min-h-screen flex items-center justify-center bg-background">
|
||||
<div className="animate-pulse text-muted-foreground text-sm">Loading...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex bg-background">
|
||||
{/* Sidebar */}
|
||||
<aside className="w-60 border-r border-border bg-secondary/30 flex flex-col">
|
||||
<div className="h-14 flex items-center px-4 border-b border-border">
|
||||
{logoUrl ? (
|
||||
<img src={logoUrl} alt="" className="w-5 h-5 object-contain mr-2" />
|
||||
) : (
|
||||
<Shield className="w-5 h-5 text-primary mr-2" />
|
||||
)}
|
||||
<span className="font-semibold text-sm text-foreground">Admin Panel</span>
|
||||
</div>
|
||||
|
||||
<nav className="flex-1 py-2 px-2 space-y-0.5">
|
||||
{NAV_ITEMS.map(({ href, label, icon: Icon }) => {
|
||||
const active = href === '/admin' ? pathname === '/admin' : pathname.startsWith(href);
|
||||
return (
|
||||
<Link
|
||||
key={href}
|
||||
href={href}
|
||||
className={cn(
|
||||
'flex items-center gap-2.5 px-3 py-2 rounded-md text-sm transition-colors',
|
||||
active
|
||||
? 'bg-accent text-accent-foreground font-medium'
|
||||
: 'text-muted-foreground hover:text-foreground hover:bg-accent/50'
|
||||
)}
|
||||
>
|
||||
<Icon className="w-4 h-4" />
|
||||
{label}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
<div className="p-2 border-t border-border space-y-0.5">
|
||||
<Link
|
||||
href="/admin/change-password"
|
||||
className="flex items-center gap-2.5 px-3 py-2 rounded-md text-sm text-muted-foreground hover:text-foreground hover:bg-accent/50 transition-colors"
|
||||
>
|
||||
<KeyRound className="w-4 h-4" />
|
||||
Change Password
|
||||
</Link>
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="flex items-center gap-2.5 px-3 py-2 rounded-md text-sm text-muted-foreground hover:text-foreground hover:bg-accent/50 transition-colors w-full text-left"
|
||||
>
|
||||
<LogOut className="w-4 h-4" />
|
||||
Sign out
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* Main content */}
|
||||
<main className="flex-1 overflow-auto">
|
||||
<div className="max-w-4xl mx-auto p-6">
|
||||
{children}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="min-h-screen flex items-center justify-center bg-background px-4">
|
||||
<div className="w-full max-w-sm">
|
||||
<div className="flex flex-col items-center mb-8">
|
||||
<div className="w-12 h-12 rounded-xl bg-primary/10 flex items-center justify-center mb-4">
|
||||
{logoUrl ? (
|
||||
<img src={logoUrl} alt="" className="w-8 h-8 object-contain" />
|
||||
) : (
|
||||
<Shield className="w-6 h-6 text-primary" />
|
||||
)}
|
||||
</div>
|
||||
<h1 className="text-xl font-semibold text-foreground">Admin Dashboard</h1>
|
||||
<p className="text-sm text-muted-foreground mt-1">Enter your admin password to continue</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="password" className="block text-sm font-medium text-foreground mb-1.5">
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="text-sm text-destructive bg-destructive/10 rounded-md px-3 py-2">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading || !password}
|
||||
className="w-full h-10 rounded-md bg-primary text-primary-foreground font-medium text-sm hover:bg-primary/90 disabled:opacity-50 disabled:pointer-events-none transition-all duration-200 shadow-sm"
|
||||
>
|
||||
{loading ? 'Signing in...' : 'Sign in'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<AuditEntry[]>([]);
|
||||
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 (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold text-foreground">Audit Log</h1>
|
||||
<p className="text-sm text-muted-foreground mt-1">{total} total entries</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={fetchLogs}
|
||||
className="inline-flex items-center gap-2 h-9 px-3 rounded-md border border-input bg-background text-sm text-foreground hover:bg-accent transition-colors"
|
||||
>
|
||||
<RefreshCw className={`w-4 h-4 ${loading ? 'animate-spin' : ''}`} />
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Filter */}
|
||||
<div className="flex items-center gap-3">
|
||||
<select
|
||||
value={actionFilter}
|
||||
onChange={(e) => { setActionFilter(e.target.value); setPage(1); }}
|
||||
className="h-8 rounded-md border border-input bg-background px-2.5 text-sm text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
>
|
||||
<option value="">All actions</option>
|
||||
<option value="admin.login">Login</option>
|
||||
<option value="admin.logout">Logout</option>
|
||||
<option value="admin.login_failed">Login Failed</option>
|
||||
<option value="admin.login_blocked">Login Blocked</option>
|
||||
<option value="admin.change-password">Password Change</option>
|
||||
<option value="config.update">Config Update</option>
|
||||
<option value="config.revert">Config Revert</option>
|
||||
<option value="policy.update">Policy Update</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<div className="border border-border rounded-lg overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border bg-muted/30">
|
||||
<th className="text-left px-4 py-2 font-medium text-muted-foreground">Time</th>
|
||||
<th className="text-left px-4 py-2 font-medium text-muted-foreground">Action</th>
|
||||
<th className="text-left px-4 py-2 font-medium text-muted-foreground">Details</th>
|
||||
<th className="text-left px-4 py-2 font-medium text-muted-foreground">IP</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{loading && entries.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={4} className="px-4 py-8 text-center text-muted-foreground">Loading...</td>
|
||||
</tr>
|
||||
) : entries.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={4} className="px-4 py-8 text-center text-muted-foreground">No entries found</td>
|
||||
</tr>
|
||||
) : (
|
||||
entries.map((entry, i) => (
|
||||
<tr key={i} className="hover:bg-muted/20">
|
||||
<td className="px-4 py-2 text-xs text-muted-foreground whitespace-nowrap">
|
||||
{new Date(entry.ts).toLocaleString()}
|
||||
</td>
|
||||
<td className="px-4 py-2">
|
||||
<span className="text-xs font-mono px-2 py-0.5 rounded bg-muted text-muted-foreground">
|
||||
{entry.action}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-2 text-xs text-foreground max-w-xs truncate">
|
||||
{formatDetail(entry.detail)}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-xs text-muted-foreground font-mono">
|
||||
{entry.ip}
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Page {page} of {totalPages}
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setPage(p => Math.max(1, p - 1))}
|
||||
disabled={page === 1}
|
||||
className="h-8 px-3 rounded-md border border-input bg-background text-sm disabled:opacity-50 hover:bg-accent transition-colors"
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setPage(p => Math.min(totalPages, p + 1))}
|
||||
disabled={page === totalPages}
|
||||
className="h-8 px-3 rounded-md border border-input bg-background text-sm disabled:opacity-50 hover:bg-accent transition-colors"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatDetail(detail: Record<string, unknown>): 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<string, unknown>) => `${c.key}`).join(', ');
|
||||
}
|
||||
if (detail.restrictionCount !== undefined) return `${detail.restrictionCount} restriction(s)`;
|
||||
return JSON.stringify(detail).slice(0, 100);
|
||||
}
|
||||
@@ -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<AdminStatus | null>(null);
|
||||
const [recentActivity, setRecentActivity] = useState<AuditEntry[]>([]);
|
||||
const [config, setConfig] = useState<ConfigData | null>(null);
|
||||
const [, setConfigSources] = useState<Record<string, { value: unknown; source: string }> | null>(null);
|
||||
const [warnings, setWarnings] = useState<string[]>([]);
|
||||
|
||||
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 (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold text-foreground">Dashboard</h1>
|
||||
<p className="text-sm text-muted-foreground mt-1">Server overview and recent activity</p>
|
||||
</div>
|
||||
|
||||
{/* Status cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<StatusCard
|
||||
icon={<Server className="w-4 h-4" />}
|
||||
label="Application"
|
||||
value={config?.appName || '—'}
|
||||
/>
|
||||
<StatusCard
|
||||
icon={<Globe className="w-4 h-4" />}
|
||||
label="JMAP Server"
|
||||
value={jmapUrl ? new URL(jmapUrl).hostname : '—'}
|
||||
detail={jmapUrl}
|
||||
/>
|
||||
<StatusCard
|
||||
icon={<Clock className="w-4 h-4" />}
|
||||
label="Last Login"
|
||||
value={status?.lastLogin ? new Date(status.lastLogin).toLocaleString() : 'Never'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Feature status row */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
<FeaturePill label="Admin" active={!!status?.enabled} />
|
||||
<FeaturePill label="Settings Sync" active={!!config?.settingsSyncEnabled} />
|
||||
<FeaturePill label="OAuth" active={!!config?.oauthEnabled} />
|
||||
<FeaturePill label="Stalwart" active={config?.stalwartFeaturesEnabled !== false} />
|
||||
</div>
|
||||
|
||||
{/* Warnings */}
|
||||
{warnings.map((msg, i) => (
|
||||
<div key={i} className="flex items-start gap-3 rounded-lg border border-amber-200 bg-amber-50 dark:border-amber-900 dark:bg-amber-950/30 p-4">
|
||||
<AlertTriangle className="w-5 h-5 text-amber-600 dark:text-amber-400 mt-0.5 shrink-0" />
|
||||
<p className="text-sm text-amber-700 dark:text-amber-300">{msg}</p>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{status && !status.lastLogin && (
|
||||
<div className="flex items-start gap-3 rounded-lg border border-amber-200 bg-amber-50 dark:border-amber-900 dark:bg-amber-950/30 p-4">
|
||||
<AlertTriangle className="w-5 h-5 text-amber-600 dark:text-amber-400 mt-0.5 shrink-0" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-amber-800 dark:text-amber-200">First login detected</p>
|
||||
<p className="text-sm text-amber-700 dark:text-amber-300 mt-0.5">
|
||||
Remember to remove ADMIN_PASSWORD from your .env file now that the hash is stored securely.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Recent activity */}
|
||||
<div>
|
||||
<h2 className="text-lg font-medium text-foreground mb-3">Recent Activity</h2>
|
||||
<div className="border border-border rounded-lg divide-y divide-border">
|
||||
{recentActivity.length === 0 ? (
|
||||
<div className="px-4 py-8 text-center text-sm text-muted-foreground">
|
||||
No activity recorded yet
|
||||
</div>
|
||||
) : (
|
||||
recentActivity.map((entry, i) => (
|
||||
<div key={i} className="px-4 py-3 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-xs font-mono px-2 py-0.5 rounded bg-muted text-muted-foreground">
|
||||
{entry.action}
|
||||
</span>
|
||||
<span className="text-sm text-foreground">
|
||||
{formatDetail(entry.detail)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-xs text-muted-foreground">
|
||||
<span>{entry.ip}</span>
|
||||
<span>{new Date(entry.ts).toLocaleString()}</span>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusCard({ icon, label, value, detail }: { icon: React.ReactNode; label: string; value: string; detail?: string }) {
|
||||
return (
|
||||
<div className="border border-border rounded-lg p-4 bg-secondary/20">
|
||||
<div className="flex items-center gap-2 text-muted-foreground mb-2">
|
||||
{icon}
|
||||
<span className="text-xs font-medium uppercase tracking-wider">{label}</span>
|
||||
</div>
|
||||
<div className="text-lg font-semibold text-foreground truncate">{value}</div>
|
||||
{detail && <p className="text-xs text-muted-foreground mt-1 truncate">{detail}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FeaturePill({ label, active }: { label: string; active: boolean }) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 border border-border rounded-md px-3 py-2">
|
||||
<span className={`w-2 h-2 rounded-full ${active ? 'bg-green-500' : 'bg-muted-foreground/40'}`} />
|
||||
<span className="text-xs font-medium text-foreground">{label}</span>
|
||||
<span className={`text-xs ml-auto ${active ? 'text-green-600 dark:text-green-400' : 'text-muted-foreground'}`}>
|
||||
{active ? 'On' : 'Off'}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatDetail(detail: Record<string, unknown>): 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);
|
||||
}
|
||||
@@ -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<keyof FeatureGates, { label: string; description: string }> = {
|
||||
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<SettingsPolicy>({ ...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 <div className="flex items-center justify-center py-12 text-muted-foreground text-sm">Loading...</div>;
|
||||
}
|
||||
|
||||
const categories = [...new Set(RESTRICTABLE_SETTINGS.map(s => s.category))];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold text-foreground">User Policy</h1>
|
||||
<p className="text-sm text-muted-foreground mt-1">Control which features and settings users can access</p>
|
||||
</div>
|
||||
{dirty && (
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
className="inline-flex items-center gap-2 h-9 px-4 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 disabled:opacity-50 transition-all shadow-sm"
|
||||
>
|
||||
{saving ? <Loader2 className="w-4 h-4 animate-spin" /> : <Save className="w-4 h-4" />}
|
||||
Save policy
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{message && (
|
||||
<div className={`text-sm rounded-md px-3 py-2 ${message.type === 'success' ? 'bg-emerald-50 text-emerald-700 dark:bg-emerald-950/30 dark:text-emerald-300' : 'bg-destructive/10 text-destructive'}`}>
|
||||
{message.text}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Feature Gates */}
|
||||
<div className="border border-border rounded-lg">
|
||||
<div className="px-4 py-3 border-b border-border bg-muted/30">
|
||||
<h2 className="text-sm font-medium text-foreground">Feature Gates</h2>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">Toggle entire features on or off for all users</p>
|
||||
</div>
|
||||
<div className="divide-y divide-border">
|
||||
{(Object.keys(DEFAULT_FEATURE_GATES) as (keyof FeatureGates)[]).map(key => {
|
||||
const { label, description } = FEATURE_GATE_LABELS[key];
|
||||
const enabled = policy.features[key];
|
||||
return (
|
||||
<div key={key} className="px-4 py-3 flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<span className="text-sm text-foreground">{label}</span>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">{description}</p>
|
||||
</div>
|
||||
<button onClick={() => toggleFeature(key)}
|
||||
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${enabled ? 'bg-primary' : 'bg-muted-foreground/30'}`}>
|
||||
<span className={`inline-block h-3.5 w-3.5 transform rounded-full bg-white shadow transition-transform ${enabled ? 'translate-x-[18px]' : 'translate-x-[3px]'}`} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Setting Restrictions */}
|
||||
{categories.map(category => (
|
||||
<div key={category} className="border border-border rounded-lg">
|
||||
<div className="px-4 py-3 border-b border-border bg-muted/30">
|
||||
<h2 className="text-sm font-medium text-foreground">{category}</h2>
|
||||
</div>
|
||||
<div className="divide-y divide-border">
|
||||
{RESTRICTABLE_SETTINGS.filter(s => s.category === category).map(setting => {
|
||||
const restriction = policy.restrictions[setting.key] || {};
|
||||
return (
|
||||
<div key={setting.key} className="px-4 py-3 flex items-center justify-between gap-4">
|
||||
<span className="text-sm text-foreground">{setting.label}</span>
|
||||
<div className="flex items-center gap-3">
|
||||
<label className="flex items-center gap-1.5 text-xs text-muted-foreground cursor-pointer">
|
||||
<input type="checkbox" checked={!!restriction.locked} onChange={() => toggleLocked(setting.key)}
|
||||
className="rounded border-input" />
|
||||
<Lock className="w-3 h-3" /> Lock
|
||||
</label>
|
||||
<label className="flex items-center gap-1.5 text-xs text-muted-foreground cursor-pointer">
|
||||
<input type="checkbox" checked={!!restriction.hidden} onChange={() => toggleHidden(setting.key)}
|
||||
className="rounded border-input" />
|
||||
Hide
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<Record<string, ConfigEntry>>({});
|
||||
const [edits, setEdits] = useState<Record<string, unknown>>({});
|
||||
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 <div className="flex items-center justify-center py-12 text-muted-foreground text-sm">Loading...</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold text-foreground">Server Settings</h1>
|
||||
<p className="text-sm text-muted-foreground mt-1">General server configuration</p>
|
||||
</div>
|
||||
{hasEdits && (
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
className="inline-flex items-center gap-2 h-9 px-4 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 disabled:opacity-50 transition-all shadow-sm"
|
||||
>
|
||||
{saving ? <Loader2 className="w-4 h-4 animate-spin" /> : <Save className="w-4 h-4" />}
|
||||
Save changes
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{message && (
|
||||
<div className={`text-sm rounded-md px-3 py-2 ${message.type === 'success' ? 'bg-emerald-50 text-emerald-700 dark:bg-emerald-950/30 dark:text-emerald-300' : 'bg-destructive/10 text-destructive'}`}>
|
||||
{message.text}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* General */}
|
||||
<SettingsSection title="General">
|
||||
<TextSetting label="Application Name" configKey="appName" value={currentValue('appName') as string} source={config.appName?.source} onChange={handleChange} onRevert={handleRevert} />
|
||||
<TextSetting label="JMAP Server URL" configKey="jmapServerUrl" value={currentValue('jmapServerUrl') as string} source={config.jmapServerUrl?.source} onChange={handleChange} onRevert={handleRevert} placeholder="https://mail.example.com" />
|
||||
<ToggleSetting label="Stalwart Features" description="Enable Stalwart Mail Server-specific features" configKey="stalwartFeaturesEnabled" value={currentValue('stalwartFeaturesEnabled') as boolean} source={config.stalwartFeaturesEnabled?.source} onChange={handleChange} onRevert={handleRevert} />
|
||||
<TextSetting label="Stalwart API URL" configKey="stalwartApiUrl" value={currentValue('stalwartApiUrl') as string} source={config.stalwartApiUrl?.source} onChange={handleChange} onRevert={handleRevert} placeholder="https://mail.example.com/api" />
|
||||
<ToggleSetting label="Demo Mode" description="Enable demo mode with sample data" configKey="demoMode" value={currentValue('demoMode') as boolean} source={config.demoMode?.source} onChange={handleChange} onRevert={handleRevert} />
|
||||
</SettingsSection>
|
||||
|
||||
{/* Logging */}
|
||||
<SettingsSection title="Logging">
|
||||
<SelectSetting label="Log Format" configKey="logFormat" value={currentValue('logFormat') as string} source={config.logFormat?.source} options={['text', 'json']} onChange={handleChange} onRevert={handleRevert} />
|
||||
<SelectSetting label="Log Level" configKey="logLevel" value={currentValue('logLevel') as string} source={config.logLevel?.source} options={['error', 'warn', 'info', 'debug']} onChange={handleChange} onRevert={handleRevert} />
|
||||
</SettingsSection>
|
||||
|
||||
{/* Settings Sync */}
|
||||
<SettingsSection title="Settings Sync">
|
||||
<ToggleSetting label="Settings Sync Enabled" description="Requires SESSION_SECRET to be set" configKey="settingsSyncEnabled" value={currentValue('settingsSyncEnabled') as boolean} source={config.settingsSyncEnabled?.source} onChange={handleChange} onRevert={handleRevert} />
|
||||
</SettingsSection>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SettingsSection({ title, children }: { title: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="border border-border rounded-lg">
|
||||
<div className="px-4 py-3 border-b border-border bg-muted/30">
|
||||
<h2 className="text-sm font-medium text-foreground">{title}</h2>
|
||||
</div>
|
||||
<div className="divide-y divide-border">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SourceBadge({ source }: { source?: string }) {
|
||||
if (!source || source === 'default') return null;
|
||||
return (
|
||||
<span className={`text-[10px] font-medium uppercase tracking-wider px-1.5 py-0.5 rounded ${source === 'admin' ? 'bg-primary/10 text-primary' : 'bg-muted text-muted-foreground'}`}>
|
||||
{source}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="px-4 py-3 flex items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<label className="text-sm text-foreground whitespace-nowrap">{label}</label>
|
||||
<SourceBadge source={source} />
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={value ?? ''}
|
||||
onChange={(e) => 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' && (
|
||||
<button onClick={() => onRevert(configKey)} className="text-muted-foreground hover:text-foreground" title="Revert to default">
|
||||
<RotateCcw className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="px-4 py-3 flex items-center justify-between gap-4">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-foreground">{label}</span>
|
||||
<SourceBadge source={source} />
|
||||
</div>
|
||||
{description && <p className="text-xs text-muted-foreground mt-0.5">{description}</p>}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => onChange(configKey, !value)}
|
||||
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${value ? 'bg-primary' : 'bg-muted-foreground/30'}`}
|
||||
>
|
||||
<span className={`inline-block h-3.5 w-3.5 transform rounded-full bg-white shadow transition-transform ${value ? 'translate-x-[18px]' : 'translate-x-[3px]'}`} />
|
||||
</button>
|
||||
{source === 'admin' && (
|
||||
<button onClick={() => onRevert(configKey)} className="text-muted-foreground hover:text-foreground" title="Revert to default">
|
||||
<RotateCcw className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="px-4 py-3 flex items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-foreground">{label}</span>
|
||||
<SourceBadge source={source} />
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<select
|
||||
value={value ?? ''}
|
||||
onChange={(e) => onChange(configKey, e.target.value)}
|
||||
className="h-8 rounded-md border border-input bg-background px-2.5 text-sm text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
>
|
||||
{options.map(opt => <option key={opt} value={opt}>{opt}</option>)}
|
||||
</select>
|
||||
{source === 'admin' && (
|
||||
<button onClick={() => onRevert(configKey)} className="text-muted-foreground hover:text-foreground" title="Revert to default">
|
||||
<RotateCcw className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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<string, string> = {
|
||||
'.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 });
|
||||
}
|
||||
}
|
||||
@@ -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<string, string> = {
|
||||
'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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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<string, unknown> = {};
|
||||
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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
+38
-27
@@ -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<string>('appName') || process.env.NEXT_PUBLIC_APP_NAME || 'Webmail';
|
||||
const jmapServerUrl = configManager.get<string>('jmapServerUrl') || process.env.NEXT_PUBLIC_JMAP_SERVER_URL || '';
|
||||
const oauthEnabled = configManager.get<boolean>('oauthEnabled', false);
|
||||
const oauthOnly = oauthEnabled && configManager.get<boolean>('oauthOnly', false);
|
||||
const stalwartFeaturesEnabled = configManager.get<boolean>('stalwartFeaturesEnabled', true);
|
||||
const allowedFrameAncestors = configManager.get<string>('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<string>('oauthClientId', ''),
|
||||
oauthIssuerUrl: configManager.get<string>('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<boolean>('settingsSyncEnabled', false) && !!process.env.SESSION_SECRET,
|
||||
stalwartFeaturesEnabled,
|
||||
devMode: configManager.get<boolean>('devMode', false),
|
||||
faviconUrl: configManager.get<string>('faviconUrl', '/branding/Bulwark_Favicon.svg'),
|
||||
appLogoLightUrl: configManager.get<string>('appLogoLightUrl', ''),
|
||||
appLogoDarkUrl: configManager.get<string>('appLogoDarkUrl', ''),
|
||||
loginLogoLightUrl: configManager.get<string>('loginLogoLightUrl', '/branding/Bulwark_Logo_Color.svg'),
|
||||
loginLogoDarkUrl: configManager.get<string>('loginLogoDarkUrl', '/branding/Bulwark_Logo_White.svg'),
|
||||
loginCompanyName: configManager.get<string>('loginCompanyName', ''),
|
||||
loginImprintUrl: configManager.get<string>('loginImprintUrl', ''),
|
||||
loginPrivacyPolicyUrl: configManager.get<string>('loginPrivacyPolicyUrl', ''),
|
||||
loginWebsiteUrl: configManager.get<string>('loginWebsiteUrl', ''),
|
||||
demoMode: configManager.get<boolean>('demoMode', false),
|
||||
autoSsoEnabled: configManager.get<boolean>('autoSsoEnabled', false),
|
||||
embeddedMode: !!allowedFrameAncestors && allowedFrameAncestors !== "'none'",
|
||||
parentOrigin: configManager.get<string>('parentOrigin', ''),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
+9
-2
@@ -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 (
|
||||
|
||||
@@ -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({
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
<PluginSlot name="composer-toolbar" />
|
||||
</div>
|
||||
|
||||
{/* Right side - Discard + Send (desktop) */}
|
||||
|
||||
@@ -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({
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
<PluginSlot name="context-menu-email" />
|
||||
</ContextMenu>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 && <span className="hidden sm:inline text-sm">{t('forward')}</span>}
|
||||
</Button>
|
||||
</>)}
|
||||
<PluginSlot name="toolbar-actions" />
|
||||
</div>
|
||||
|
||||
{/* Right: Organize actions — order: archive, delete, move, star, tag, spam, read state, print, view source */}
|
||||
@@ -4443,6 +4445,8 @@ export function EmailViewer({
|
||||
|
||||
<div>
|
||||
|
||||
<PluginSlot name="email-banner" />
|
||||
|
||||
{/* Email Body */}
|
||||
<div className="email-content-wrapper overflow-x-auto">
|
||||
{effectiveEmailContent.isHtml ? (
|
||||
@@ -4470,6 +4474,8 @@ export function EmailViewer({
|
||||
)}
|
||||
</div>
|
||||
|
||||
<PluginSlot name="email-footer" />
|
||||
|
||||
{/* Quick Reply Section - hidden for drafts */}
|
||||
{!isDraft && (<div className={cn(
|
||||
"mt-6 mx-6 mb-6 bg-background rounded-lg shadow-sm border transition-all",
|
||||
|
||||
@@ -5,13 +5,17 @@ import { createPortal } from "react-dom";
|
||||
import { Mail, Calendar, BookUser, HardDrive, Settings, LogOut, Keyboard, Plus } from "lucide-react";
|
||||
import { AccountSwitcher } from "./account-switcher";
|
||||
import { icons as lucideIcons, type LucideIcon } from "lucide-react";
|
||||
import { useConfig } from "@/hooks/use-config";
|
||||
import { useThemeStore } from "@/stores/theme-store";
|
||||
import { usePathname, Link } from "@/i18n/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useCalendarStore } from "@/stores/calendar-store";
|
||||
import { useEmailStore } from "@/stores/email-store";
|
||||
import { useWebDAVStore } from "@/stores/webdav-store";
|
||||
import { useSettingsStore } from "@/stores/settings-store";
|
||||
import { usePolicyStore } from "@/stores/policy-store";
|
||||
import { cn, formatFileSize } from "@/lib/utils";
|
||||
import { PluginSlot } from "@/components/plugins/plugin-slot";
|
||||
|
||||
interface NavItem {
|
||||
id: string;
|
||||
@@ -152,10 +156,14 @@ export function NavigationRail({
|
||||
}: NavigationRailProps) {
|
||||
const t = useTranslations("sidebar");
|
||||
const pathname = usePathname();
|
||||
const { appLogoLightUrl, appLogoDarkUrl } = useConfig();
|
||||
const resolvedTheme = useThemeStore((s) => 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 ? (
|
||||
<div className="flex items-center justify-center py-3 px-1">
|
||||
<img
|
||||
src={logoUrl}
|
||||
alt=""
|
||||
className="w-8 h-8 object-contain"
|
||||
/>
|
||||
</div>
|
||||
) : null;
|
||||
})()}
|
||||
|
||||
<nav
|
||||
className={cn(
|
||||
"flex flex-col",
|
||||
@@ -333,7 +354,7 @@ export function NavigationRail({
|
||||
})}
|
||||
|
||||
{/* Custom sidebar apps */}
|
||||
{sidebarApps.length > 0 && (
|
||||
{visibleSidebarApps.length > 0 && (
|
||||
<div
|
||||
className={cn(
|
||||
"border-t",
|
||||
@@ -342,7 +363,7 @@ export function NavigationRail({
|
||||
style={{ borderColor: 'rgba(128, 128, 128, 0.3)' }}
|
||||
/>
|
||||
)}
|
||||
{sidebarApps.map((app) => {
|
||||
{visibleSidebarApps.map((app) => {
|
||||
const AppIcon = lucideIcons[app.icon as keyof typeof lucideIcons] as LucideIcon | undefined;
|
||||
const isActive = activeAppId === app.id;
|
||||
return (
|
||||
@@ -397,6 +418,8 @@ export function NavigationRail({
|
||||
)}
|
||||
</nav>
|
||||
|
||||
<PluginSlot name="navigation-rail-bottom" />
|
||||
|
||||
{/* Footer: Settings + Help + Storage Quota + Sign Out + Push Status */}
|
||||
<div className="mt-auto flex flex-col items-center gap-2 pb-3 px-1">
|
||||
<Link
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useRouter } from "@/i18n/navigation";
|
||||
import { PluginSlot } from "@/components/plugins/plugin-slot";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Inbox,
|
||||
@@ -41,8 +42,6 @@ import { useSettingsStore, KEYWORD_PALETTE, KeywordDefinition } from "@/stores/s
|
||||
import { useEmailStore } from "@/stores/email-store";
|
||||
import { toast } from "@/stores/toast-store";
|
||||
import { debug } from "@/lib/debug";
|
||||
import { useConfig } from "@/hooks/use-config";
|
||||
import { useThemeStore } from "@/stores/theme-store";
|
||||
import { AccountSwitcher } from "./account-switcher";
|
||||
import { useTour } from "@/components/tour/tour-provider";
|
||||
|
||||
@@ -431,8 +430,6 @@ export function Sidebar({
|
||||
}: SidebarProps) {
|
||||
const { sidebarCollapsed: isCollapsed, toggleSidebarCollapsed } = useUIStore();
|
||||
const { primaryIdentity } = useAuthStore();
|
||||
const { appLogoLightUrl, appLogoDarkUrl } = useConfig();
|
||||
const resolvedTheme = useThemeStore((s) => s.resolvedTheme);
|
||||
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(new Set());
|
||||
const [tagsExpanded, setTagsExpanded] = useState(() => {
|
||||
try {
|
||||
@@ -532,17 +529,6 @@ export function Sidebar({
|
||||
<X className="w-5 h-5" />
|
||||
</Button>
|
||||
|
||||
{(() => {
|
||||
const logoUrl = resolvedTheme === 'dark' ? (appLogoDarkUrl || appLogoLightUrl) : (appLogoLightUrl || appLogoDarkUrl);
|
||||
return logoUrl ? (
|
||||
<img
|
||||
src={logoUrl}
|
||||
alt=""
|
||||
className={cn("object-contain flex-shrink-0", isCollapsed ? "w-6 h-6" : "w-6 h-6")}
|
||||
/>
|
||||
) : null;
|
||||
})()}
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
@@ -673,6 +659,8 @@ export function Sidebar({
|
||||
)}
|
||||
</div>
|
||||
|
||||
<PluginSlot name="sidebar-widget" />
|
||||
|
||||
{/* Compose Button */}
|
||||
<div className={cn("border-t border-border", isCollapsed ? "flex justify-center py-3" : "px-3 py-3")}>
|
||||
{isCollapsed ? (
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
|
||||
export interface PluginErrorBoundaryProps {
|
||||
pluginId: string;
|
||||
children?: React.ReactNode;
|
||||
fallback?: React.ReactNode;
|
||||
}
|
||||
|
||||
interface PluginErrorBoundaryState {
|
||||
hasError: boolean;
|
||||
error?: Error;
|
||||
}
|
||||
|
||||
export class PluginErrorBoundary extends React.Component<PluginErrorBoundaryProps, PluginErrorBoundaryState> {
|
||||
constructor(props: PluginErrorBoundaryProps) {
|
||||
super(props);
|
||||
this.state = { hasError: false };
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error: Error): PluginErrorBoundaryState {
|
||||
return { hasError: true, error };
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, errorInfo: React.ErrorInfo): void {
|
||||
console.error(`[plugin:${this.props.pluginId}] Render error:`, error, errorInfo);
|
||||
}
|
||||
|
||||
render(): React.ReactNode {
|
||||
if (this.state.hasError) {
|
||||
return this.props.fallback ?? null;
|
||||
}
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import type { SlotRegistration } from '@/lib/plugin-types';
|
||||
import { PluginErrorBoundary } from './plugin-error-boundary';
|
||||
|
||||
interface PluginSlotRendererProps {
|
||||
registration: SlotRegistration;
|
||||
fallback?: React.ReactNode;
|
||||
extraProps?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export function PluginSlotRenderer({ registration, fallback = null, extraProps }: PluginSlotRendererProps) {
|
||||
const Component = registration.component;
|
||||
|
||||
return (
|
||||
<PluginErrorBoundary pluginId={registration.pluginId} fallback={fallback}>
|
||||
<Component {...(extraProps ?? {})} />
|
||||
</PluginErrorBoundary>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import type { SlotName } from '@/lib/plugin-types';
|
||||
import { usePluginStore } from '@/stores/plugin-store';
|
||||
import { PluginSlotRenderer } from './plugin-slot-renderer';
|
||||
|
||||
interface PluginSlotProps {
|
||||
name: SlotName;
|
||||
className?: string;
|
||||
extraProps?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export function PluginSlot({ name, className, extraProps }: PluginSlotProps) {
|
||||
const registrations = usePluginStore(s => s.slots[name]);
|
||||
|
||||
if (!registrations || registrations.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className={className} data-plugin-slot={name}>
|
||||
{registrations.map((reg, i) => (
|
||||
<PluginSlotRenderer
|
||||
key={`${reg.pluginId}-${i}`}
|
||||
registration={reg}
|
||||
extraProps={extraProps}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,286 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useRef } from 'react';
|
||||
import { usePluginStore } from '@/stores/plugin-store';
|
||||
import { SettingsSection, SettingItem, ToggleSwitch } from './settings-section';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Upload, Trash2, AlertTriangle, Puzzle } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { toast } from '@/stores/toast-store';
|
||||
import type { InstalledPlugin, PluginStatus, SettingFieldSchema } from '@/lib/plugin-types';
|
||||
|
||||
const STATUS_COLORS: Record<PluginStatus, string> = {
|
||||
installed: 'bg-muted text-muted-foreground',
|
||||
enabled: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400',
|
||||
running: 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400',
|
||||
disabled: 'bg-muted text-muted-foreground',
|
||||
error: 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400',
|
||||
};
|
||||
|
||||
export function PluginsSettings() {
|
||||
const { plugins, installPlugin, uninstallPlugin, enablePlugin, disablePlugin, updatePluginSettings } = usePluginStore();
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const [expandedPlugin, setExpandedPlugin] = useState<string | null>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const handleUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
setIsUploading(true);
|
||||
try {
|
||||
const result = await installPlugin(file);
|
||||
if (result.success) {
|
||||
toast.success('Plugin installed');
|
||||
if (result.warnings?.length) {
|
||||
toast.warning('Plugin warnings', { message: result.warnings.join('\n') });
|
||||
}
|
||||
} else {
|
||||
toast.error('Plugin installation failed', { message: result.error });
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error('Plugin installation failed', { message: err instanceof Error ? err.message : 'Unknown error' });
|
||||
} finally {
|
||||
setIsUploading(false);
|
||||
if (fileInputRef.current) fileInputRef.current.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggle = async (plugin: InstalledPlugin) => {
|
||||
if (plugin.enabled) {
|
||||
disablePlugin(plugin.id);
|
||||
toast.info(`Plugin "${plugin.name}" disabled`);
|
||||
} else {
|
||||
await enablePlugin(plugin.id);
|
||||
toast.success(`Plugin "${plugin.name}" enabled`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUninstall = (plugin: InstalledPlugin) => {
|
||||
uninstallPlugin(plugin.id);
|
||||
toast.success(`Plugin "${plugin.name}" removed`);
|
||||
};
|
||||
|
||||
return (
|
||||
<SettingsSection title="Plugins" description="Manage installed plugins. Upload plugin .zip files to add new functionality.">
|
||||
{/* Plugin List */}
|
||||
{plugins.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<Puzzle className="w-12 h-12 text-muted-foreground/30 mb-3" />
|
||||
<p className="text-sm text-muted-foreground mb-1">No plugins installed</p>
|
||||
<p className="text-xs text-muted-foreground/70">Upload a plugin .zip file to get started</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{plugins.map(plugin => (
|
||||
<PluginCard
|
||||
key={plugin.id}
|
||||
plugin={plugin}
|
||||
isExpanded={expandedPlugin === plugin.id}
|
||||
onToggleExpand={() => setExpandedPlugin(expandedPlugin === plugin.id ? null : plugin.id)}
|
||||
onToggle={() => handleToggle(plugin)}
|
||||
onUninstall={() => handleUninstall(plugin)}
|
||||
onUpdateSettings={(settings) => updatePluginSettings(plugin.id, settings)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Upload */}
|
||||
<SettingItem label="Upload Plugin" description="Install a new plugin from a .zip file">
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".zip"
|
||||
onChange={handleUpload}
|
||||
className="hidden"
|
||||
aria-label="Upload plugin file"
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={isUploading}
|
||||
>
|
||||
<Upload className="w-4 h-4 mr-1.5" />
|
||||
{isUploading ? 'Installing...' : 'Upload .zip'}
|
||||
</Button>
|
||||
</SettingItem>
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Plugin Card ─────────────────────────────────────────────
|
||||
|
||||
interface PluginCardProps {
|
||||
plugin: InstalledPlugin;
|
||||
isExpanded: boolean;
|
||||
onToggleExpand: () => void;
|
||||
onToggle: () => void;
|
||||
onUninstall: () => void;
|
||||
onUpdateSettings: (settings: Record<string, unknown>) => void;
|
||||
}
|
||||
|
||||
function PluginCard({ plugin, isExpanded, onToggleExpand, onToggle, onUninstall, onUpdateSettings }: PluginCardProps) {
|
||||
return (
|
||||
<div className={cn(
|
||||
'rounded-lg border transition-colors',
|
||||
plugin.status === 'error' ? 'border-destructive/40' : 'border-border',
|
||||
)}>
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-3 p-3">
|
||||
<div className="flex-1 min-w-0 cursor-pointer" onClick={onToggleExpand}>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium text-foreground truncate">{plugin.name}</span>
|
||||
<span className={cn('text-[10px] px-1.5 py-0.5 rounded-full font-medium', STATUS_COLORS[plugin.status])}>
|
||||
{plugin.status}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-0.5">
|
||||
<span className="text-xs text-muted-foreground">{plugin.author}</span>
|
||||
<span className="text-xs text-muted-foreground/50">v{plugin.version}</span>
|
||||
<span className="text-xs text-muted-foreground/50">{plugin.type}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
<ToggleSwitch checked={plugin.enabled} onChange={onToggle} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Expanded Details */}
|
||||
{isExpanded && (
|
||||
<div className="border-t border-border p-3 space-y-3">
|
||||
{/* Description */}
|
||||
{plugin.description && (
|
||||
<p className="text-xs text-muted-foreground">{plugin.description}</p>
|
||||
)}
|
||||
|
||||
{/* Error */}
|
||||
{plugin.error && (
|
||||
<div className="flex items-start gap-2 p-2 rounded bg-destructive/10 text-destructive text-xs">
|
||||
<AlertTriangle className="w-3.5 h-3.5 flex-shrink-0 mt-0.5" />
|
||||
<span>{plugin.error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Permissions */}
|
||||
{plugin.permissions.length > 0 && (
|
||||
<div>
|
||||
<span className="text-xs font-medium text-foreground">Permissions:</span>
|
||||
<div className="flex flex-wrap gap-1 mt-1">
|
||||
{plugin.permissions.map(perm => (
|
||||
<span key={perm} className="text-[10px] px-1.5 py-0.5 rounded bg-muted text-muted-foreground">
|
||||
{perm}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Settings (auto-generated from schema) */}
|
||||
{plugin.settingsSchema && Object.keys(plugin.settingsSchema).length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<span className="text-xs font-medium text-foreground">Settings:</span>
|
||||
{Object.entries(plugin.settingsSchema).map(([key, schema]) => (
|
||||
<PluginSettingField
|
||||
key={key}
|
||||
fieldKey={key}
|
||||
schema={schema}
|
||||
value={plugin.settings[key] ?? schema.default}
|
||||
onChange={(value) => onUpdateSettings({ [key]: value })}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Uninstall */}
|
||||
<div className="flex justify-end pt-2 border-t border-border">
|
||||
<Button variant="destructive" size="sm" onClick={onUninstall}>
|
||||
<Trash2 className="w-3.5 h-3.5 mr-1" />
|
||||
Uninstall
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Auto-generated Setting Field ────────────────────────────
|
||||
|
||||
interface PluginSettingFieldProps {
|
||||
fieldKey: string;
|
||||
schema: SettingFieldSchema;
|
||||
value: unknown;
|
||||
onChange: (value: unknown) => void;
|
||||
}
|
||||
|
||||
function PluginSettingField({ schema, value, onChange }: PluginSettingFieldProps) {
|
||||
switch (schema.type) {
|
||||
case 'boolean':
|
||||
return (
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<span className="text-xs text-foreground">{schema.label}</span>
|
||||
{schema.description && <p className="text-[10px] text-muted-foreground">{schema.description}</p>}
|
||||
</div>
|
||||
<ToggleSwitch checked={value as boolean} onChange={(v) => onChange(v)} />
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'select':
|
||||
return (
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<span className="text-xs text-foreground">{schema.label}</span>
|
||||
{schema.description && <p className="text-[10px] text-muted-foreground">{schema.description}</p>}
|
||||
</div>
|
||||
<select
|
||||
value={String(value)}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
className="text-xs bg-background border border-border rounded px-2 py-1 text-foreground"
|
||||
>
|
||||
{schema.options?.map(opt => (
|
||||
<option key={opt} value={opt}>{opt}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'string':
|
||||
return (
|
||||
<div>
|
||||
<span className="text-xs text-foreground">{schema.label}</span>
|
||||
{schema.description && <p className="text-[10px] text-muted-foreground">{schema.description}</p>}
|
||||
<input
|
||||
type="text"
|
||||
value={String(value ?? '')}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
className="mt-1 w-full text-xs bg-background border border-border rounded px-2 py-1 text-foreground"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'number':
|
||||
return (
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<span className="text-xs text-foreground">{schema.label}</span>
|
||||
{schema.description && <p className="text-[10px] text-muted-foreground">{schema.description}</p>}
|
||||
</div>
|
||||
<input
|
||||
type="number"
|
||||
value={Number(value ?? schema.default ?? 0)}
|
||||
min={schema.min}
|
||||
max={schema.max}
|
||||
onChange={(e) => onChange(Number(e.target.value))}
|
||||
className="w-20 text-xs bg-background border border-border rounded px-2 py-1 text-foreground"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { ReactNode } from 'react';
|
||||
import { Lock } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface SettingsSectionProps {
|
||||
@@ -25,18 +26,22 @@ interface SettingItemProps {
|
||||
label: string;
|
||||
description?: string;
|
||||
children: ReactNode;
|
||||
locked?: boolean;
|
||||
}
|
||||
|
||||
export function SettingItem({ label, description, children }: SettingItemProps) {
|
||||
export function SettingItem({ label, description, children, locked }: SettingItemProps) {
|
||||
return (
|
||||
<div className="flex items-start justify-between py-3 border-b border-border last:border-0">
|
||||
<div className={cn("flex items-start justify-between py-3 border-b border-border last:border-0", locked && "opacity-60")}>
|
||||
<div className="flex-1 pr-4">
|
||||
<label className="text-sm font-medium text-foreground">{label}</label>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<label className="text-sm font-medium text-foreground">{label}</label>
|
||||
{locked && <Lock className="w-3 h-3 text-muted-foreground" aria-label="Managed by administrator" />}
|
||||
</div>
|
||||
{description && (
|
||||
<p className="text-xs text-muted-foreground mt-1">{description}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-shrink-0">{children}</div>
|
||||
<div className={cn("flex-shrink-0", locked && "pointer-events-none")}>{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useRef } from 'react';
|
||||
import { useThemeStore } from '@/stores/theme-store';
|
||||
import { SettingsSection, SettingItem } from './settings-section';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Upload, Trash2, Check, Palette } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { toast } from '@/stores/toast-store';
|
||||
import type { InstalledTheme } from '@/lib/plugin-types';
|
||||
|
||||
export function ThemesSettings() {
|
||||
const { installedThemes, activeThemeId, installTheme, uninstallTheme, activateTheme } = useThemeStore();
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const handleUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
setIsUploading(true);
|
||||
try {
|
||||
const result = await installTheme(file);
|
||||
if (result.success) {
|
||||
toast.success('Theme installed');
|
||||
if (result.warnings?.length) {
|
||||
toast.warning('Theme warnings', { message: result.warnings.join('\n') });
|
||||
}
|
||||
} else {
|
||||
toast.error('Theme installation failed', { message: result.error });
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error('Theme installation failed', { message: err instanceof Error ? err.message : 'Unknown error' });
|
||||
} finally {
|
||||
setIsUploading(false);
|
||||
// Reset file input
|
||||
if (fileInputRef.current) fileInputRef.current.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
const handleActivate = (id: string | null) => {
|
||||
activateTheme(id);
|
||||
toast.success(id ? 'Theme activated' : 'Default theme restored');
|
||||
};
|
||||
|
||||
const handleUninstall = (theme: InstalledTheme) => {
|
||||
if (theme.builtIn) return;
|
||||
uninstallTheme(theme.id);
|
||||
toast.success('Theme removed');
|
||||
};
|
||||
|
||||
return (
|
||||
<SettingsSection title="Themes" description="Customize the appearance with color themes. Upload .zip theme files or activate built-in presets.">
|
||||
{/* Theme Grid */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3">
|
||||
{/* Default theme card */}
|
||||
<ThemeCard
|
||||
name="Default"
|
||||
author="Bulwark"
|
||||
isActive={activeThemeId === null}
|
||||
isBuiltIn
|
||||
onActivate={() => handleActivate(null)}
|
||||
/>
|
||||
|
||||
{/* Installed themes */}
|
||||
{installedThemes.map(theme => (
|
||||
<ThemeCard
|
||||
key={theme.id}
|
||||
name={theme.name}
|
||||
author={theme.author}
|
||||
preview={theme.preview}
|
||||
isActive={activeThemeId === theme.id}
|
||||
isBuiltIn={theme.builtIn}
|
||||
variants={theme.variants}
|
||||
onActivate={() => handleActivate(theme.id)}
|
||||
onRemove={!theme.builtIn ? () => handleUninstall(theme) : undefined}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Upload */}
|
||||
<SettingItem label="Upload Theme" description="Install a custom theme from a .zip file containing manifest.json and theme.css">
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".zip"
|
||||
onChange={handleUpload}
|
||||
className="hidden"
|
||||
aria-label="Upload theme file"
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={isUploading}
|
||||
>
|
||||
<Upload className="w-4 h-4 mr-1.5" />
|
||||
{isUploading ? 'Installing...' : 'Upload .zip'}
|
||||
</Button>
|
||||
</SettingItem>
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Theme Card ──────────────────────────────────────────────
|
||||
|
||||
interface ThemeCardProps {
|
||||
name: string;
|
||||
author: string;
|
||||
preview?: string;
|
||||
isActive: boolean;
|
||||
isBuiltIn: boolean;
|
||||
variants?: ('light' | 'dark')[];
|
||||
onActivate: () => void;
|
||||
onRemove?: () => void;
|
||||
}
|
||||
|
||||
function ThemeCard({ name, author, preview, isActive, variants, onActivate, onRemove }: ThemeCardProps) {
|
||||
return (
|
||||
<button
|
||||
onClick={onActivate}
|
||||
className={cn(
|
||||
'relative flex flex-col items-center p-3 rounded-xl border-2 transition-all text-left w-full',
|
||||
isActive
|
||||
? 'border-primary bg-primary/5 ring-1 ring-primary/20'
|
||||
: 'border-border hover:border-primary/40 bg-card'
|
||||
)}
|
||||
>
|
||||
{/* Preview / Placeholder */}
|
||||
<div className="w-full aspect-[16/10] rounded-lg mb-2 overflow-hidden bg-muted flex items-center justify-center">
|
||||
{preview ? (
|
||||
<img src={preview} alt={name} className="w-full h-full object-cover" />
|
||||
) : (
|
||||
<Palette className="w-8 h-8 text-muted-foreground/40" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Info */}
|
||||
<div className="w-full">
|
||||
<div className="flex items-center justify-between gap-1">
|
||||
<span className="text-sm font-medium text-foreground truncate">{name}</span>
|
||||
{isActive && <Check className="w-4 h-4 text-primary flex-shrink-0" />}
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground truncate block">{author}</span>
|
||||
{variants && (
|
||||
<div className="flex gap-1 mt-1">
|
||||
{variants.map(v => (
|
||||
<span key={v} className="text-[10px] px-1.5 py-0.5 rounded bg-muted text-muted-foreground">
|
||||
{v}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Remove button */}
|
||||
{onRemove && !isActive && (
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); onRemove(); }}
|
||||
className="absolute top-2 right-2 p-1 rounded-md hover:bg-destructive/10 text-muted-foreground hover:text-destructive transition-colors"
|
||||
title="Remove theme"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -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<ConfigData> {
|
||||
})
|
||||
.then((data) => {
|
||||
configCache = data;
|
||||
// Fetch admin policy alongside config (non-blocking)
|
||||
usePolicyStore.getState().fetchPolicy();
|
||||
return data;
|
||||
})
|
||||
.finally(() => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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> = {}): 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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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<string, SlotRegistration[]> = {};
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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> = {}): 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<File> {
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<string, unknown>, ip: string): Promise<void> {
|
||||
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<void> {
|
||||
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 };
|
||||
}
|
||||
}
|
||||
@@ -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<string, unknown> = {};
|
||||
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<void> {
|
||||
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<void> {
|
||||
if (!this.loaded) await this.load();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a config value. Priority: admin override > env var > default.
|
||||
*/
|
||||
get<T>(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<string, unknown> {
|
||||
const result: Record<string, unknown> = {};
|
||||
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<string, { value: unknown; source: 'admin' | 'env' | 'default' }> {
|
||||
const result: Record<string, { value: unknown; source: 'admin' | 'env' | 'default' }> = {};
|
||||
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<string, unknown>): Promise<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
this.policyCache = { ...DEFAULT_POLICY, ...policy };
|
||||
await this.writeJsonFile('policy.json', this.policyCache as unknown as Record<string, unknown>);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reload config from disk (for manual file edits or multi-instance).
|
||||
*/
|
||||
async reload(): Promise<void> {
|
||||
await this.load();
|
||||
}
|
||||
|
||||
private async readJsonFile(filename: string): Promise<Record<string, unknown> | 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<string, unknown>): Promise<void> {
|
||||
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();
|
||||
@@ -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<string> {
|
||||
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$<salt_base64>$<hash_base64>
|
||||
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<boolean> {
|
||||
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<string, number> = {};
|
||||
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<AdminData | null> {
|
||||
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<void> {
|
||||
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<boolean> {
|
||||
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<boolean> {
|
||||
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<boolean> {
|
||||
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<void> {
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -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<string, RateLimitEntry>();
|
||||
|
||||
// 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 };
|
||||
}
|
||||
@@ -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<void> {
|
||||
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<void> {
|
||||
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';
|
||||
}
|
||||
@@ -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<string, SettingRestriction>;
|
||||
features: FeatureGates;
|
||||
defaults: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export const DEFAULT_POLICY: SettingsPolicy = {
|
||||
restrictions: {},
|
||||
features: { ...DEFAULT_FEATURE_GATES },
|
||||
defaults: {},
|
||||
};
|
||||
|
||||
export interface AuditEntry {
|
||||
ts: string;
|
||||
action: string;
|
||||
detail: Record<string, unknown>;
|
||||
ip: string;
|
||||
}
|
||||
|
||||
/** Config keys that map to environment variables */
|
||||
export const CONFIG_ENV_MAP: Record<string, { envVar: string; type: 'string' | 'boolean' | 'url' | 'enum'; defaultValue: unknown; enumValues?: string[] }> = {
|
||||
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;
|
||||
@@ -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,
|
||||
},
|
||||
];
|
||||
@@ -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<T extends (...args: never[]) => 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: <T>(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: <T>(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<string, unknown> };
|
||||
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<typeof createPluginStorage>;
|
||||
log: ReturnType<typeof createPluginLogger>;
|
||||
}
|
||||
|
||||
// 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<string, Permission> = {
|
||||
// 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<string, { register: (pluginId: string, handler: (...args: unknown[]) => 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<Record<string, unknown>>; order: number }) => Disposable) | null = null;
|
||||
|
||||
export function setSlotRegistrationBridge(fn: typeof registerSlotFn): void {
|
||||
registerSlotFn = fn;
|
||||
}
|
||||
|
||||
function registerSlot(
|
||||
pluginId: string,
|
||||
slotName: SlotName,
|
||||
component: React.ComponentType<Record<string, unknown>>,
|
||||
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<string, unknown>)[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<string, unknown>)[hookName] = (shortcut: KeyboardShortcut) => {
|
||||
return guardedHook(plugin, perm, bus, shortcut.handler);
|
||||
};
|
||||
} else {
|
||||
(hooks as unknown as Record<string, unknown>)[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<Record<string, unknown>>, action.order ?? 100);
|
||||
},
|
||||
|
||||
registerEmailBanner: (factory: BannerFactory) => {
|
||||
requirePermission(plugin, 'ui:email-banner');
|
||||
return registerSlot(plugin.id, 'email-banner', factory.render as unknown as React.ComponentType<Record<string, unknown>>, 100);
|
||||
},
|
||||
|
||||
registerEmailFooter: (component: React.ComponentType) => {
|
||||
requirePermission(plugin, 'ui:email-footer');
|
||||
return registerSlot(plugin.id, 'email-footer', component as React.ComponentType<Record<string, unknown>>, 100);
|
||||
},
|
||||
|
||||
registerSettingsSection: (section: SettingsSection) => {
|
||||
requirePermission(plugin, 'ui:settings-section');
|
||||
return registerSlot(plugin.id, 'settings-section', section.render as React.ComponentType<Record<string, unknown>>, 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<Record<string, unknown>>, action.order ?? 100);
|
||||
},
|
||||
|
||||
registerSidebarWidget: (widget: SidebarWidget) => {
|
||||
requirePermission(plugin, 'ui:sidebar-widget');
|
||||
return registerSlot(plugin.id, 'sidebar-widget', widget.render as React.ComponentType<Record<string, unknown>>, 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<Record<string, unknown>>, item.order ?? 100);
|
||||
},
|
||||
|
||||
registerNavigationRailItem: (component: React.ComponentType) => {
|
||||
requirePermission(plugin, 'ui:navigation-rail');
|
||||
return registerSlot(plugin.id, 'navigation-rail-bottom', component as React.ComponentType<Record<string, unknown>>, 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),
|
||||
};
|
||||
}
|
||||
@@ -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<string, ErrorRecord>();
|
||||
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<T>(promise: T | Promise<T>, ms: number = DEFAULT_TIMEOUT_MS): Promise<T> {
|
||||
if (!(promise instanceof Promise)) return Promise.resolve(promise);
|
||||
return new Promise<T>((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<T extends (...args: never[]) => unknown> {
|
||||
pluginId: string;
|
||||
handler: T;
|
||||
order: number;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export class HookBus<T extends (...args: any[]) => any> {
|
||||
private handlers: HookEntry<T>[] = [];
|
||||
|
||||
register(pluginId: string, handler: T, order: number = 100): Disposable {
|
||||
const entry: HookEntry<T> = { 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<T>): Promise<void> {
|
||||
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<T>): 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<T>): Promise<boolean> {
|
||||
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<V>(initial: V, ...rest: unknown[]): Promise<V> {
|
||||
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<never>).removePlugin(pluginId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function clearAllHooks(): void {
|
||||
for (const group of allHookGroups) {
|
||||
for (const bus of Object.values(group)) {
|
||||
(bus as HookBus<never>).clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<string, ActivePlugin>();
|
||||
|
||||
// ─── 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<void> {
|
||||
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<void> {
|
||||
// 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');
|
||||
});
|
||||
}
|
||||
@@ -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<IDBDatabase> {
|
||||
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<void> {
|
||||
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<T = string>(storeName: string, key: string): Promise<T | null> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
await putItem(STORE_PLUGINS, pluginId, code);
|
||||
},
|
||||
async getCode(pluginId: string): Promise<string | null> {
|
||||
return getItem<string>(STORE_PLUGINS, pluginId);
|
||||
},
|
||||
async deleteCode(pluginId: string): Promise<void> {
|
||||
await deleteItem(STORE_PLUGINS, pluginId);
|
||||
},
|
||||
|
||||
// Theme CSS blobs
|
||||
async saveThemeCSS(themeId: string, css: string): Promise<void> {
|
||||
await putItem(STORE_THEMES, themeId, css);
|
||||
},
|
||||
async getThemeCSS(themeId: string): Promise<string | null> {
|
||||
return getItem<string>(STORE_THEMES, themeId);
|
||||
},
|
||||
async deleteThemeCSS(themeId: string): Promise<void> {
|
||||
await deleteItem(STORE_THEMES, themeId);
|
||||
},
|
||||
|
||||
// Preview images (stored as data URIs)
|
||||
async savePreview(id: string, dataUri: string): Promise<void> {
|
||||
await putItem(STORE_PREVIEWS, id, dataUri);
|
||||
},
|
||||
async getPreview(id: string): Promise<string | null> {
|
||||
return getItem<string>(STORE_PREVIEWS, id);
|
||||
},
|
||||
async deletePreview(id: string): Promise<void> {
|
||||
await deleteItem(STORE_PREVIEWS, id);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,397 @@
|
||||
// Plugin & Theme system types
|
||||
|
||||
// ─── Common ──────────────────────────────────────────────────
|
||||
|
||||
export type Disposable = { dispose: () => void };
|
||||
export type MaybePromise<T> = T | Promise<T>;
|
||||
|
||||
export type PluginType = 'ui-extension' | 'sidebar-app' | 'hook' | 'theme';
|
||||
export type PluginStatus = 'installed' | 'enabled' | 'running' | 'disabled' | 'error';
|
||||
export type ThemeVariant = 'light' | 'dark';
|
||||
|
||||
// ─── Manifests ───────────────────────────────────────────────
|
||||
|
||||
export interface ThemeManifest {
|
||||
id: string;
|
||||
name: string;
|
||||
version: string;
|
||||
author: string;
|
||||
description: string;
|
||||
type: 'theme';
|
||||
preview?: string;
|
||||
variants: ThemeVariant[];
|
||||
minAppVersion?: string;
|
||||
}
|
||||
|
||||
export interface PluginManifest {
|
||||
id: string;
|
||||
name: string;
|
||||
version: string;
|
||||
author: string;
|
||||
description: string;
|
||||
type: Exclude<PluginType, 'theme'>;
|
||||
permissions: string[];
|
||||
entrypoint: string;
|
||||
minAppVersion?: string;
|
||||
settingsSchema?: Record<string, SettingFieldSchema>;
|
||||
}
|
||||
|
||||
export interface SettingFieldSchema {
|
||||
type: 'boolean' | 'string' | 'number' | 'select';
|
||||
label: string;
|
||||
description?: string;
|
||||
default: unknown;
|
||||
options?: string[];
|
||||
min?: number;
|
||||
max?: number;
|
||||
}
|
||||
|
||||
// ─── Installed Items ─────────────────────────────────────────
|
||||
|
||||
export interface InstalledTheme {
|
||||
id: string;
|
||||
name: string;
|
||||
version: string;
|
||||
author: string;
|
||||
description: string;
|
||||
preview?: string; // data: URI or blob URL
|
||||
css: string; // raw CSS text
|
||||
variants: ThemeVariant[];
|
||||
enabled: boolean;
|
||||
builtIn: boolean;
|
||||
}
|
||||
|
||||
export interface InstalledPlugin {
|
||||
id: string;
|
||||
name: string;
|
||||
version: string;
|
||||
author: string;
|
||||
description: string;
|
||||
type: Exclude<PluginType, 'theme'>;
|
||||
permissions: string[];
|
||||
entrypoint: string;
|
||||
enabled: boolean;
|
||||
status: PluginStatus;
|
||||
error?: string;
|
||||
settingsSchema?: Record<string, SettingFieldSchema>;
|
||||
settings: Record<string, unknown>;
|
||||
}
|
||||
|
||||
// ─── UI Slots ────────────────────────────────────────────────
|
||||
|
||||
export type SlotName =
|
||||
| 'toolbar-actions'
|
||||
| 'email-banner'
|
||||
| 'email-footer'
|
||||
| 'composer-toolbar'
|
||||
| 'sidebar-widget'
|
||||
| 'settings-section'
|
||||
| 'context-menu-email'
|
||||
| 'navigation-rail-bottom';
|
||||
|
||||
export interface SlotRegistration {
|
||||
pluginId: string;
|
||||
component: React.ComponentType<Record<string, unknown>>;
|
||||
order: number;
|
||||
}
|
||||
|
||||
// ─── Plugin API Types ────────────────────────────────────────
|
||||
|
||||
export interface ToolbarAction {
|
||||
id: string;
|
||||
label: string;
|
||||
icon?: string;
|
||||
onClick: () => void;
|
||||
order?: number;
|
||||
}
|
||||
|
||||
export interface BannerFactory {
|
||||
shouldShow: (email: EmailReadView) => boolean;
|
||||
render: React.ComponentType<{ email: EmailReadView }>;
|
||||
}
|
||||
|
||||
export interface SettingsSection {
|
||||
id: string;
|
||||
label: string;
|
||||
icon?: string;
|
||||
render: React.ComponentType;
|
||||
}
|
||||
|
||||
export interface ComposerAction {
|
||||
id: string;
|
||||
label: string;
|
||||
icon?: string;
|
||||
onClick: () => void;
|
||||
order?: number;
|
||||
}
|
||||
|
||||
export interface SidebarWidget {
|
||||
id: string;
|
||||
label: string;
|
||||
render: React.ComponentType;
|
||||
order?: number;
|
||||
}
|
||||
|
||||
export interface ContextMenuItem {
|
||||
id: string;
|
||||
label: string;
|
||||
icon?: string;
|
||||
onClick: (emailIds: string[]) => void;
|
||||
order?: number;
|
||||
}
|
||||
|
||||
export interface KeyboardShortcut {
|
||||
id: string;
|
||||
keys: string;
|
||||
label: string;
|
||||
category: string;
|
||||
handler: () => void;
|
||||
}
|
||||
|
||||
// ─── Read-Only View Types ────────────────────────────────────
|
||||
// Projected views exposed to plugins — no direct store references
|
||||
|
||||
export interface EmailReadView {
|
||||
id: string;
|
||||
threadId: string;
|
||||
mailboxIds: string[];
|
||||
from: { name: string; email: string }[];
|
||||
to: { name: string; email: string }[];
|
||||
cc: { name: string; email: string }[];
|
||||
subject: string;
|
||||
receivedAt: string;
|
||||
isRead: boolean;
|
||||
isFlagged: boolean;
|
||||
hasAttachment: boolean;
|
||||
preview: string;
|
||||
keywords: string[];
|
||||
}
|
||||
|
||||
export interface DraftView {
|
||||
to: string[];
|
||||
cc: string[];
|
||||
bcc: string[];
|
||||
subject: string;
|
||||
htmlBody: string;
|
||||
textBody: string;
|
||||
identityId: string;
|
||||
inReplyTo?: string;
|
||||
attachments: { name: string; type: string; size: number }[];
|
||||
}
|
||||
|
||||
export interface MailboxView {
|
||||
id: string;
|
||||
name: string;
|
||||
role: string | null;
|
||||
totalEmails: number;
|
||||
unreadEmails: number;
|
||||
parentId: string | null;
|
||||
}
|
||||
|
||||
export interface CalendarEventView {
|
||||
id: string;
|
||||
calendarId: string;
|
||||
title: string;
|
||||
description: string;
|
||||
start: string;
|
||||
end: string;
|
||||
isAllDay: boolean;
|
||||
location: string;
|
||||
status: string;
|
||||
recurrenceRule?: string;
|
||||
}
|
||||
|
||||
export interface CalendarView {
|
||||
id: string;
|
||||
name: string;
|
||||
color: string;
|
||||
isVisible: boolean;
|
||||
isDefault: boolean;
|
||||
}
|
||||
|
||||
export interface ContactView {
|
||||
id: string;
|
||||
addressBookId: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
emails: string[];
|
||||
phones: string[];
|
||||
company: string;
|
||||
notes: string;
|
||||
}
|
||||
|
||||
export interface AddressBookView {
|
||||
id: string;
|
||||
name: string;
|
||||
isDefault: boolean;
|
||||
}
|
||||
|
||||
export interface ContactGroupView {
|
||||
id: string;
|
||||
name: string;
|
||||
memberCount: number;
|
||||
}
|
||||
|
||||
export interface FileResourceView {
|
||||
id: string;
|
||||
name: string;
|
||||
type: 'file' | 'directory';
|
||||
size: number;
|
||||
mimeType: string;
|
||||
path: string;
|
||||
modified: string;
|
||||
}
|
||||
|
||||
export interface IdentityView {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
replyTo: string | null;
|
||||
bcc: string | null;
|
||||
htmlSignature: string;
|
||||
textSignature: string;
|
||||
}
|
||||
|
||||
export interface TaskView {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
isComplete: boolean;
|
||||
dueDate: string | null;
|
||||
priority: string;
|
||||
calendarId: string;
|
||||
}
|
||||
|
||||
export interface TemplateView {
|
||||
id: string;
|
||||
name: string;
|
||||
subject: string;
|
||||
htmlBody: string;
|
||||
textBody: string;
|
||||
}
|
||||
|
||||
export interface FilterRuleView {
|
||||
id: string;
|
||||
name: string;
|
||||
isActive: boolean;
|
||||
conditions: unknown[];
|
||||
actions: unknown[];
|
||||
}
|
||||
|
||||
export interface KeywordView {
|
||||
id: string;
|
||||
name: string;
|
||||
color: string;
|
||||
}
|
||||
|
||||
export interface QuotaView {
|
||||
used: number;
|
||||
total: number;
|
||||
percentUsed: number;
|
||||
}
|
||||
|
||||
export interface CalendarAlertView {
|
||||
id: string;
|
||||
eventId: string;
|
||||
eventTitle: string;
|
||||
triggerTime: string;
|
||||
}
|
||||
|
||||
export interface SearchFilters {
|
||||
from?: string;
|
||||
to?: string;
|
||||
subject?: string;
|
||||
hasAttachment?: boolean;
|
||||
after?: string;
|
||||
before?: string;
|
||||
inMailbox?: string;
|
||||
}
|
||||
|
||||
export interface NewEmailNotification {
|
||||
emailId: string;
|
||||
from: { name: string; email: string };
|
||||
subject: string;
|
||||
preview: string;
|
||||
}
|
||||
|
||||
export interface VacationView {
|
||||
isEnabled: boolean;
|
||||
subject: string;
|
||||
htmlBody: string;
|
||||
textBody: string;
|
||||
fromDate: string | null;
|
||||
toDate: string | null;
|
||||
}
|
||||
|
||||
export interface KeyboardEventView {
|
||||
key: string;
|
||||
code: string;
|
||||
ctrlKey: boolean;
|
||||
shiftKey: boolean;
|
||||
altKey: boolean;
|
||||
metaKey: boolean;
|
||||
}
|
||||
|
||||
export interface AppConfigView {
|
||||
appName: string;
|
||||
demoMode: boolean;
|
||||
stalwartFeaturesEnabled: boolean;
|
||||
oauthEnabled: boolean;
|
||||
}
|
||||
|
||||
export interface FileInfo {
|
||||
name: string;
|
||||
size: number;
|
||||
type: string;
|
||||
}
|
||||
|
||||
export interface ComposerContext {
|
||||
mode: 'new' | 'reply' | 'reply-all' | 'forward';
|
||||
inReplyToId?: string;
|
||||
originalSubject?: string;
|
||||
}
|
||||
|
||||
// ─── Permission Reference ────────────────────────────────────
|
||||
|
||||
export const ALL_PERMISSIONS = [
|
||||
'email:read', 'email:write', 'email:send',
|
||||
'calendar:read', 'calendar:write',
|
||||
'contacts:read', 'contacts:write',
|
||||
'files:read', 'files:write',
|
||||
'identity:read', 'identity:write',
|
||||
'filters:read', 'filters:write',
|
||||
'tasks:read', 'tasks:write',
|
||||
'templates:read', 'templates:write',
|
||||
'smime:read',
|
||||
'vacation:read', 'vacation:write',
|
||||
'settings:read', 'settings:write',
|
||||
'security:read',
|
||||
'auth:observe',
|
||||
'ui:observe', 'ui:toolbar', 'ui:email-banner', 'ui:email-footer',
|
||||
'ui:composer-toolbar', 'ui:sidebar-widget', 'ui:settings-section',
|
||||
'ui:context-menu', 'ui:navigation-rail', 'ui:keyboard',
|
||||
'app:lifecycle',
|
||||
] as const;
|
||||
|
||||
export type Permission = (typeof ALL_PERMISSIONS)[number];
|
||||
|
||||
/** Permissions always granted regardless of manifest */
|
||||
export const IMPLICIT_PERMISSIONS: Permission[] = ['ui:observe', 'app:lifecycle'];
|
||||
|
||||
// ─── Validation ──────────────────────────────────────────────
|
||||
|
||||
export const MAX_PLUGIN_SIZE = 5 * 1024 * 1024; // 5 MB
|
||||
export const MAX_THEME_SIZE = 1 * 1024 * 1024; // 1 MB
|
||||
|
||||
export const ALLOWED_PLUGIN_FILES = new Set([
|
||||
'.js', '.mjs', '.css', '.json', '.png', '.svg', '.woff2', '.jpg', '.jpeg', '.webp',
|
||||
]);
|
||||
|
||||
export const DISALLOWED_CSS_PATTERNS = [
|
||||
/@import\b/i,
|
||||
/url\s*\(\s*['"]?https?:/i,
|
||||
/expression\s*\(/i,
|
||||
/javascript\s*:/i,
|
||||
/-moz-binding/i,
|
||||
/behavior\s*:/i,
|
||||
];
|
||||
@@ -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, unknown>): 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<string, unknown>): { 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<string, unknown>): { 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<ThemeExtractionResult> {
|
||||
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<string, unknown>;
|
||||
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<PluginExtractionResult> {
|
||||
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<string, unknown>;
|
||||
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<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => resolve(reader.result as string);
|
||||
reader.onerror = () => reject(reader.error);
|
||||
reader.readAsDataURL(blob);
|
||||
});
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
Generated
+96
-2
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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<typeof intlMiddleware> | 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();
|
||||
|
||||
|
||||
@@ -0,0 +1,244 @@
|
||||
// Plugin store — manages installed plugins, slot registrations, and lifecycle
|
||||
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
import type {
|
||||
InstalledPlugin,
|
||||
PluginStatus,
|
||||
SlotName,
|
||||
SlotRegistration,
|
||||
Disposable,
|
||||
} from '@/lib/plugin-types';
|
||||
import { pluginStorage } from '@/lib/plugin-storage';
|
||||
import { extractPlugin } from '@/lib/plugin-validator';
|
||||
import { loadPlugin, deactivatePlugin, setPluginStoreAccessor, setupAutoDisable } from '@/lib/plugin-loader';
|
||||
import { setSlotRegistrationBridge } from '@/lib/plugin-api';
|
||||
import { removeAllPluginHooks } from '@/lib/plugin-hooks';
|
||||
|
||||
// ─── Slot State ──────────────────────────────────────────────
|
||||
|
||||
const SLOT_NAMES: SlotName[] = [
|
||||
'toolbar-actions', 'email-banner', 'email-footer', 'composer-toolbar',
|
||||
'sidebar-widget', 'settings-section', 'context-menu-email', 'navigation-rail-bottom',
|
||||
];
|
||||
|
||||
function emptySlots(): Record<SlotName, SlotRegistration[]> {
|
||||
const slots = {} as Record<SlotName, SlotRegistration[]>;
|
||||
for (const name of SLOT_NAMES) {
|
||||
slots[name] = [];
|
||||
}
|
||||
return slots;
|
||||
}
|
||||
|
||||
// ─── Store Interface ─────────────────────────────────────────
|
||||
|
||||
interface PluginStoreState {
|
||||
plugins: InstalledPlugin[];
|
||||
slots: Record<SlotName, SlotRegistration[]>;
|
||||
initialized: boolean;
|
||||
|
||||
// Management
|
||||
installPlugin: (file: File) => Promise<{ success: boolean; error?: string; warnings?: string[] }>;
|
||||
uninstallPlugin: (id: string) => void;
|
||||
enablePlugin: (id: string) => Promise<void>;
|
||||
disablePlugin: (id: string) => void;
|
||||
updatePluginSettings: (id: string, settings: Record<string, unknown>) => void;
|
||||
|
||||
// Runtime (called by plugin loader / API bridge)
|
||||
registerSlot: (slotName: SlotName, registration: SlotRegistration) => Disposable;
|
||||
setPluginStatus: (id: string, status: PluginStatus, error?: string) => void;
|
||||
|
||||
// Init
|
||||
initializePlugins: () => Promise<void>;
|
||||
}
|
||||
|
||||
// ─── Store ───────────────────────────────────────────────────
|
||||
|
||||
export const usePluginStore = create<PluginStoreState>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
plugins: [],
|
||||
slots: emptySlots(),
|
||||
initialized: false,
|
||||
|
||||
installPlugin: async (file: File) => {
|
||||
const result = await extractPlugin(file);
|
||||
if (!result.valid || !result.manifest) {
|
||||
return { success: false, error: result.errors.join('; '), warnings: result.warnings };
|
||||
}
|
||||
|
||||
const { manifest, code } = result;
|
||||
const { plugins } = get();
|
||||
|
||||
// Check for duplicate
|
||||
const existing = plugins.find(p => p.id === manifest.id);
|
||||
if (existing) {
|
||||
// Update: deactivate old, replace
|
||||
deactivatePlugin(manifest.id);
|
||||
}
|
||||
|
||||
const plugin: InstalledPlugin = {
|
||||
id: manifest.id,
|
||||
name: manifest.name,
|
||||
version: manifest.version,
|
||||
author: manifest.author,
|
||||
description: manifest.description,
|
||||
type: manifest.type,
|
||||
permissions: manifest.permissions,
|
||||
entrypoint: manifest.entrypoint,
|
||||
enabled: false, // Start disabled, user must enable
|
||||
status: 'installed',
|
||||
settings: existing?.settings ?? {},
|
||||
settingsSchema: manifest.settingsSchema,
|
||||
};
|
||||
|
||||
// Save code to IndexedDB
|
||||
await pluginStorage.saveCode(manifest.id, code);
|
||||
|
||||
if (existing) {
|
||||
set({ plugins: plugins.map(p => p.id === manifest.id ? plugin : p) });
|
||||
} else {
|
||||
set({ plugins: [...plugins, plugin] });
|
||||
}
|
||||
|
||||
return { success: true, warnings: result.warnings };
|
||||
},
|
||||
|
||||
uninstallPlugin: (id: string) => {
|
||||
const { plugins } = get();
|
||||
const plugin = plugins.find(p => p.id === id);
|
||||
if (!plugin) return;
|
||||
|
||||
// Deactivate if running
|
||||
deactivatePlugin(id);
|
||||
removeAllPluginHooks(id);
|
||||
|
||||
// Clean up storage
|
||||
pluginStorage.deleteCode(id);
|
||||
pluginStorage.deletePreview(id);
|
||||
|
||||
// Remove plugin-scoped localStorage entries
|
||||
if (typeof window !== 'undefined') {
|
||||
const prefix = `plugin:${id}:`;
|
||||
const keysToRemove: string[] = [];
|
||||
for (let i = 0; i < localStorage.length; i++) {
|
||||
const key = localStorage.key(i);
|
||||
if (key?.startsWith(prefix)) keysToRemove.push(key);
|
||||
}
|
||||
keysToRemove.forEach(k => localStorage.removeItem(k));
|
||||
}
|
||||
|
||||
set({ plugins: plugins.filter(p => p.id !== id) });
|
||||
},
|
||||
|
||||
enablePlugin: async (id: string) => {
|
||||
const { plugins } = get();
|
||||
const plugin = plugins.find(p => p.id === id);
|
||||
if (!plugin) return;
|
||||
|
||||
set({
|
||||
plugins: plugins.map(p =>
|
||||
p.id === id ? { ...p, enabled: true, status: 'enabled' as PluginStatus, error: undefined } : p
|
||||
),
|
||||
});
|
||||
|
||||
// Load it immediately
|
||||
const updatedPlugin = get().plugins.find(p => p.id === id);
|
||||
if (updatedPlugin) {
|
||||
await loadPlugin(updatedPlugin);
|
||||
}
|
||||
},
|
||||
|
||||
disablePlugin: (id: string) => {
|
||||
const { plugins } = get();
|
||||
deactivatePlugin(id);
|
||||
|
||||
set({
|
||||
plugins: plugins.map(p =>
|
||||
p.id === id ? { ...p, enabled: false, status: 'disabled' as PluginStatus, error: undefined } : p
|
||||
),
|
||||
});
|
||||
},
|
||||
|
||||
updatePluginSettings: (id: string, settings: Record<string, unknown>) => {
|
||||
const { plugins } = get();
|
||||
set({
|
||||
plugins: plugins.map(p =>
|
||||
p.id === id ? { ...p, settings: { ...p.settings, ...settings } } : p
|
||||
),
|
||||
});
|
||||
},
|
||||
|
||||
registerSlot: (slotName: SlotName, registration: SlotRegistration): Disposable => {
|
||||
set(state => ({
|
||||
slots: {
|
||||
...state.slots,
|
||||
[slotName]: [
|
||||
...state.slots[slotName],
|
||||
registration,
|
||||
].sort((a, b) => a.order - b.order),
|
||||
},
|
||||
}));
|
||||
|
||||
return {
|
||||
dispose: () => {
|
||||
set(state => ({
|
||||
slots: {
|
||||
...state.slots,
|
||||
[slotName]: state.slots[slotName].filter(r => r !== registration),
|
||||
},
|
||||
}));
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
setPluginStatus: (id: string, status: PluginStatus, error?: string) => {
|
||||
set(state => ({
|
||||
plugins: state.plugins.map(p =>
|
||||
p.id === id ? { ...p, status, error } : p
|
||||
),
|
||||
}));
|
||||
},
|
||||
|
||||
initializePlugins: async () => {
|
||||
if (get().initialized) return;
|
||||
|
||||
// Wire up bridges
|
||||
setPluginStoreAccessor({
|
||||
setPluginStatus: get().setPluginStatus,
|
||||
});
|
||||
setSlotRegistrationBridge(get().registerSlot);
|
||||
setupAutoDisable();
|
||||
|
||||
// Load all enabled plugins
|
||||
const enabledPlugins = get().plugins.filter(p => p.enabled && p.status !== 'error');
|
||||
for (const plugin of enabledPlugins) {
|
||||
await loadPlugin(plugin);
|
||||
}
|
||||
|
||||
set({ initialized: true });
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: 'plugin-storage',
|
||||
partialize: (state) => ({
|
||||
plugins: state.plugins.map(p => ({
|
||||
...p,
|
||||
// Reset runtime state on persist
|
||||
status: p.enabled ? 'enabled' : 'installed',
|
||||
error: undefined,
|
||||
})),
|
||||
// Don't persist slots — they are runtime-only, rebuilt on load
|
||||
}),
|
||||
onRehydrateStorage: () => {
|
||||
return (state) => {
|
||||
if (state) {
|
||||
// Ensure slots are initialized after rehydration
|
||||
state.slots = emptySlots();
|
||||
state.initialized = false;
|
||||
}
|
||||
};
|
||||
},
|
||||
}
|
||||
)
|
||||
);
|
||||
@@ -0,0 +1,55 @@
|
||||
import { create } from 'zustand';
|
||||
import type { SettingsPolicy, FeatureGates, SettingRestriction } from '@/lib/admin/types';
|
||||
import { DEFAULT_POLICY } from '@/lib/admin/types';
|
||||
|
||||
interface PolicyState {
|
||||
policy: SettingsPolicy;
|
||||
loaded: boolean;
|
||||
fetchPolicy: () => Promise<void>;
|
||||
isSettingLocked: (key: string) => boolean;
|
||||
isSettingHidden: (key: string) => boolean;
|
||||
isFeatureEnabled: (feature: keyof FeatureGates) => boolean;
|
||||
getRestriction: (key: string) => SettingRestriction | undefined;
|
||||
getEffectiveDefault: (key: string) => unknown;
|
||||
}
|
||||
|
||||
export const usePolicyStore = create<PolicyState>()((set, get) => ({
|
||||
policy: { ...DEFAULT_POLICY },
|
||||
loaded: false,
|
||||
|
||||
fetchPolicy: async () => {
|
||||
try {
|
||||
const res = await fetch('/api/admin/policy');
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
set({ policy: data, loaded: true });
|
||||
} else {
|
||||
set({ loaded: true });
|
||||
}
|
||||
} catch {
|
||||
set({ loaded: true });
|
||||
}
|
||||
},
|
||||
|
||||
isSettingLocked: (key) => {
|
||||
const r = get().policy.restrictions[key];
|
||||
return r?.locked === true;
|
||||
},
|
||||
|
||||
isSettingHidden: (key) => {
|
||||
const r = get().policy.restrictions[key];
|
||||
return r?.hidden === true;
|
||||
},
|
||||
|
||||
isFeatureEnabled: (feature) => {
|
||||
return get().policy.features[feature] ?? true;
|
||||
},
|
||||
|
||||
getRestriction: (key) => {
|
||||
return get().policy.restrictions[key];
|
||||
},
|
||||
|
||||
getEffectiveDefault: (key) => {
|
||||
return get().policy.defaults[key];
|
||||
},
|
||||
}));
|
||||
+173
-4
@@ -1,5 +1,10 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
import type { InstalledTheme, ThemeVariant } from '@/lib/plugin-types';
|
||||
import { pluginStorage } from '@/lib/plugin-storage';
|
||||
import { injectThemeCSS, removeThemeCSS, sanitizeThemeCSS } from '@/lib/theme-loader';
|
||||
import { extractTheme } from '@/lib/plugin-validator';
|
||||
import { BUILTIN_THEMES } from '@/lib/builtin-themes';
|
||||
|
||||
type Theme = 'light' | 'dark' | 'system';
|
||||
|
||||
@@ -7,9 +12,19 @@ interface ThemeState {
|
||||
theme: Theme;
|
||||
resolvedTheme: 'light' | 'dark';
|
||||
hydrated: boolean;
|
||||
|
||||
// Custom theme system
|
||||
installedThemes: InstalledTheme[];
|
||||
activeThemeId: string | null; // null = built-in default
|
||||
|
||||
setTheme: (theme: Theme) => void;
|
||||
toggleTheme: () => void;
|
||||
initializeTheme: () => void;
|
||||
|
||||
// Custom theme management
|
||||
installTheme: (file: File) => Promise<{ success: boolean; error?: string; warnings?: string[] }>;
|
||||
uninstallTheme: (id: string) => void;
|
||||
activateTheme: (id: string | null) => void;
|
||||
}
|
||||
|
||||
const getSystemTheme = (): 'light' | 'dark' => {
|
||||
@@ -43,11 +58,19 @@ export const useThemeStore = create<ThemeState>()(
|
||||
theme: 'system',
|
||||
resolvedTheme: 'light',
|
||||
hydrated: false,
|
||||
installedThemes: [...BUILTIN_THEMES],
|
||||
activeThemeId: null,
|
||||
|
||||
setTheme: (theme) => {
|
||||
const resolvedTheme = theme === 'system' ? getSystemTheme() : theme;
|
||||
applyTheme(resolvedTheme);
|
||||
set({ theme, resolvedTheme });
|
||||
// Re-apply active custom theme for new mode
|
||||
const { activeThemeId, installedThemes } = get();
|
||||
if (activeThemeId) {
|
||||
const t = installedThemes.find(t => t.id === activeThemeId);
|
||||
if (t) applyCustomThemeCSS(t, resolvedTheme);
|
||||
}
|
||||
},
|
||||
|
||||
toggleTheme: () => {
|
||||
@@ -59,11 +82,34 @@ export const useThemeStore = create<ThemeState>()(
|
||||
},
|
||||
|
||||
initializeTheme: () => {
|
||||
const { theme } = get();
|
||||
const { theme, activeThemeId, installedThemes } = get();
|
||||
const resolvedTheme = theme === 'system' ? getSystemTheme() : theme;
|
||||
applyTheme(resolvedTheme);
|
||||
set({ resolvedTheme, hydrated: true });
|
||||
|
||||
// Apply active custom theme on boot
|
||||
if (activeThemeId) {
|
||||
const t = installedThemes.find(t => t.id === activeThemeId);
|
||||
if (t) {
|
||||
// Load CSS from IndexedDB (may have been stripped from localStorage)
|
||||
if (t.css) {
|
||||
applyCustomThemeCSS(t, resolvedTheme);
|
||||
} else {
|
||||
pluginStorage.getThemeCSS(activeThemeId).then(css => {
|
||||
if (css) {
|
||||
injectThemeCSS(css);
|
||||
// Update the in-memory cache
|
||||
set({
|
||||
installedThemes: installedThemes.map(
|
||||
it => it.id === activeThemeId ? { ...it, css } : it
|
||||
),
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up previous listener if any
|
||||
if (mediaQueryCleanup) {
|
||||
mediaQueryCleanup();
|
||||
@@ -73,11 +119,15 @@ export const useThemeStore = create<ThemeState>()(
|
||||
if (typeof window !== 'undefined') {
|
||||
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
const handleChange = () => {
|
||||
const { theme } = get();
|
||||
const { theme, activeThemeId, installedThemes } = get();
|
||||
if (theme === 'system') {
|
||||
const newResolvedTheme = getSystemTheme();
|
||||
applyTheme(newResolvedTheme);
|
||||
set({ resolvedTheme: newResolvedTheme });
|
||||
if (activeThemeId) {
|
||||
const t = installedThemes.find(t => t.id === activeThemeId);
|
||||
if (t) applyCustomThemeCSS(t, newResolvedTheme);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -85,13 +135,122 @@ export const useThemeStore = create<ThemeState>()(
|
||||
mediaQueryCleanup = () => mediaQuery.removeEventListener('change', handleChange);
|
||||
}
|
||||
},
|
||||
|
||||
installTheme: async (file: File) => {
|
||||
const result = await extractTheme(file);
|
||||
if (!result.valid || !result.manifest) {
|
||||
return { success: false, error: result.errors.join('; '), warnings: result.warnings };
|
||||
}
|
||||
|
||||
const { manifest, css, preview } = result;
|
||||
const { installedThemes } = get();
|
||||
|
||||
// Check for duplicate
|
||||
if (installedThemes.some(t => t.id === manifest.id)) {
|
||||
// Update existing
|
||||
const sanitized = sanitizeThemeCSS(css);
|
||||
const theme: InstalledTheme = {
|
||||
id: manifest.id,
|
||||
name: manifest.name,
|
||||
version: manifest.version,
|
||||
author: manifest.author,
|
||||
description: manifest.description || '',
|
||||
preview: preview || undefined,
|
||||
css: sanitized.css,
|
||||
variants: manifest.variants,
|
||||
enabled: true,
|
||||
builtIn: false,
|
||||
};
|
||||
|
||||
await pluginStorage.saveThemeCSS(manifest.id, sanitized.css);
|
||||
if (preview) await pluginStorage.savePreview(manifest.id, preview);
|
||||
|
||||
set({
|
||||
installedThemes: installedThemes.map(t =>
|
||||
t.id === manifest.id ? theme : t
|
||||
),
|
||||
});
|
||||
|
||||
return { success: true, warnings: [...result.warnings, ...sanitized.warnings] };
|
||||
}
|
||||
|
||||
// Install new
|
||||
const sanitized = sanitizeThemeCSS(css);
|
||||
const theme: InstalledTheme = {
|
||||
id: manifest.id,
|
||||
name: manifest.name,
|
||||
version: manifest.version,
|
||||
author: manifest.author,
|
||||
description: manifest.description || '',
|
||||
preview: preview || undefined,
|
||||
css: sanitized.css,
|
||||
variants: manifest.variants,
|
||||
enabled: true,
|
||||
builtIn: false,
|
||||
};
|
||||
|
||||
await pluginStorage.saveThemeCSS(manifest.id, sanitized.css);
|
||||
if (preview) await pluginStorage.savePreview(manifest.id, preview);
|
||||
|
||||
set({ installedThemes: [...installedThemes, theme] });
|
||||
return { success: true, warnings: [...result.warnings, ...sanitized.warnings] };
|
||||
},
|
||||
|
||||
uninstallTheme: (id: string) => {
|
||||
const { installedThemes, activeThemeId } = get();
|
||||
const theme = installedThemes.find(t => t.id === id);
|
||||
if (!theme || theme.builtIn) return;
|
||||
|
||||
// Deactivate if active
|
||||
if (activeThemeId === id) {
|
||||
removeThemeCSS();
|
||||
set({ activeThemeId: null });
|
||||
}
|
||||
|
||||
// Clean up storage
|
||||
pluginStorage.deleteThemeCSS(id);
|
||||
pluginStorage.deletePreview(id);
|
||||
|
||||
set({
|
||||
installedThemes: installedThemes.filter(t => t.id !== id),
|
||||
});
|
||||
},
|
||||
|
||||
activateTheme: (id: string | null) => {
|
||||
if (id === null) {
|
||||
removeThemeCSS();
|
||||
set({ activeThemeId: null });
|
||||
return;
|
||||
}
|
||||
|
||||
const { installedThemes, resolvedTheme } = get();
|
||||
const theme = installedThemes.find(t => t.id === id);
|
||||
if (!theme) return;
|
||||
|
||||
applyCustomThemeCSS(theme, resolvedTheme);
|
||||
set({ activeThemeId: id });
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: 'theme-storage',
|
||||
partialize: (state) => ({ theme: state.theme }),
|
||||
partialize: (state) => ({
|
||||
theme: state.theme,
|
||||
activeThemeId: state.activeThemeId,
|
||||
// Store theme metadata but NOT full CSS (that goes in IndexedDB)
|
||||
installedThemes: state.installedThemes.map(t => ({
|
||||
...t,
|
||||
css: t.builtIn ? t.css : '', // only keep CSS for built-in themes
|
||||
preview: undefined, // previews also in IndexedDB
|
||||
})),
|
||||
}),
|
||||
onRehydrateStorage: () => {
|
||||
return (state) => {
|
||||
if (state) {
|
||||
// Ensure built-in themes are always present after rehydration
|
||||
const builtInIds = new Set(BUILTIN_THEMES.map(t => t.id));
|
||||
const userThemes = state.installedThemes.filter(t => !builtInIds.has(t.id));
|
||||
state.installedThemes = [...BUILTIN_THEMES, ...userThemes];
|
||||
|
||||
// Re-apply theme immediately after rehydration
|
||||
const resolvedTheme = state.theme === 'system' ? getSystemTheme() : state.theme;
|
||||
applyTheme(resolvedTheme);
|
||||
@@ -102,4 +261,14 @@ export const useThemeStore = create<ThemeState>()(
|
||||
},
|
||||
}
|
||||
)
|
||||
);
|
||||
);
|
||||
|
||||
/** Apply a custom theme's CSS, filtering to the appropriate variant */
|
||||
function applyCustomThemeCSS(theme: InstalledTheme, resolvedTheme: 'light' | 'dark'): void {
|
||||
// If theme only supports one variant and current mode doesn't match, skip
|
||||
if (!theme.variants.includes(resolvedTheme as ThemeVariant)) {
|
||||
removeThemeCSS();
|
||||
return;
|
||||
}
|
||||
injectThemeCSS(theme.css);
|
||||
}
|
||||
Reference in New Issue
Block a user