feat: add plugin/theme harness and admin dashboard

Plugin & Theme System:
- Add plugin type definitions, permissions (30+), and validation constants
- Add IndexedDB storage layer for plugin code, theme CSS, and previews
- Add theme CSS sanitization, injection, and safety validation
- Add HookBus event system with 130+ hooks across 20 domains
- Add plugin ZIP extraction and manifest validation with JS security checks
- Add sandboxed PluginAPI factory with scoped storage, logging, and permission gating
- Add plugin loader with blob URL dynamic import and auto-disable circuit breaker
- Add 3 built-in themes (Nord, Catppuccin, Solarized)
- Add Zustand plugin store with install/uninstall/enable/disable lifecycle
- Add PluginSlot, PluginSlotRenderer, and PluginErrorBoundary components
- Add plugins and themes settings UI panels
- Integrate plugin slots into email viewer, composer, navigation rail, sidebar, and context menu
- Extend theme store with custom theme installation and activation

Admin Dashboard:
- Add admin authentication with scrypt password hashing and AES-256-GCM sessions
- Add rate-limited login (5 attempts/15min per IP)
- Add config manager with admin override > env var > default priority
- Add settings policy system with feature gates and per-setting restrictions
- Add audit logging with rotation
- Add admin API routes (login, logout, config, policy, audit, password change)
- Add admin UI pages (login, dashboard, config, policy, audit)
- Add policy store for client-side feature gate enforcement
- Wire admin password initialization into server instrumentation

Tests:
- Add 139 tests across 10 test files covering all plugin/theme modules
This commit is contained in:
Linus Rath
2026-03-25 00:44:03 +01:00
parent 78bcf8db1b
commit 76b21147e4
63 changed files with 7894 additions and 67 deletions
+26 -3
View File
@@ -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 -15
View File
@@ -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 ? (