diff --git a/app/[locale]/settings/page.tsx b/app/[locale]/settings/page.tsx index c239ea8e..11fd0c3a 100644 --- a/app/[locale]/settings/page.tsx +++ b/app/[locale]/settings/page.tsx @@ -162,8 +162,8 @@ export default function SettingsPage() { { 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 }] : []), ...(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 }, + ...(isFeatureEnabled('themesEnabled') ? [{ id: 'themes' as Tab, label: 'Themes', icon: tabIcons.themes, group: 'system' as TabGroup }] : []), + ...(isFeatureEnabled('pluginsEnabled') ? [{ id: 'plugins' as Tab, label: 'Plugins', icon: tabIcons.plugins, group: 'system' as TabGroup }] : []), { id: 'advanced', label: t('tabs.advanced'), icon: tabIcons.advanced, group: 'system' }, ]; diff --git a/app/admin/auth/page.tsx b/app/admin/auth/page.tsx index e3aaca23..05fed172 100644 --- a/app/admin/auth/page.tsx +++ b/app/admin/auth/page.tsx @@ -182,8 +182,8 @@ function Toggle({ label, description, configKey, value, source, onChange, onReve
{source === 'admin' && ( diff --git a/app/admin/layout.tsx b/app/admin/layout.tsx index 4c367f4e..c7eb1734 100644 --- a/app/admin/layout.tsx +++ b/app/admin/layout.tsx @@ -12,6 +12,8 @@ import { ScrollText, LogOut, KeyRound, + Puzzle, + SwatchBook, } from 'lucide-react'; import { cn } from '@/lib/utils'; import { useConfig } from '@/hooks/use-config'; @@ -23,6 +25,8 @@ const NAV_ITEMS = [ { href: '/admin/branding', label: 'Branding', icon: Palette }, { href: '/admin/auth', label: 'Authentication', icon: Shield }, { href: '/admin/policy', label: 'Policy', icon: Scale }, + { href: '/admin/plugins', label: 'Plugins', icon: Puzzle }, + { href: '/admin/themes', label: 'Themes', icon: SwatchBook }, { href: '/admin/logs', label: 'Audit Log', icon: ScrollText }, ]; diff --git a/app/admin/page.tsx b/app/admin/page.tsx index 98f031c8..cdb46717 100644 --- a/app/admin/page.tsx +++ b/app/admin/page.tsx @@ -1,7 +1,7 @@ 'use client'; import { useEffect, useState } from 'react'; -import { Server, AlertTriangle, Clock, Globe } from 'lucide-react'; +import { Server, AlertTriangle, Clock, Globe, Package, Palette, Shield, Activity } from 'lucide-react'; import type { AuditEntry } from '@/lib/admin/types'; interface AdminStatus { @@ -26,17 +26,24 @@ export default function AdminDashboardPage() { const [config, setConfig] = useState(null); const [, setConfigSources] = useState | null>(null); const [warnings, setWarnings] = useState([]); + const [pluginCount, setPluginCount] = useState(0); + const [themeCount, setThemeCount] = useState(0); + const [policyRuleCount, setPolicyRuleCount] = useState(0); + const [jmapHealth, setJmapHealth] = useState<'unknown' | 'ok' | 'error'>('unknown'); useEffect(() => { fetchDashboardData(); }, []); async function fetchDashboardData() { - const [statusRes, auditRes, configRes, adminConfigRes] = await Promise.all([ + const [statusRes, auditRes, configRes, adminConfigRes, pluginRes, themeRes, policyRes] = await Promise.all([ fetch('/api/admin/auth'), fetch('/api/admin/audit?limit=10'), fetch('/api/config'), fetch('/api/admin/config'), + fetch('/api/admin/plugins').catch(() => null), + fetch('/api/admin/themes').catch(() => null), + fetch('/api/admin/policy').catch(() => null), ]); if (statusRes.ok) setStatus(await statusRes.json()); @@ -44,7 +51,37 @@ export default function AdminDashboardPage() { const data = await auditRes.json(); setRecentActivity(data.entries || []); } - if (configRes.ok) setConfig(await configRes.json()); + let configData: ConfigData | null = null; + if (configRes.ok) { + configData = await configRes.json(); + setConfig(configData); + } + + // Plugin/theme/policy stats + if (pluginRes?.ok) { + const plugins = await pluginRes.json(); + setPluginCount(Array.isArray(plugins) ? plugins.length : 0); + } + if (themeRes?.ok) { + const themes = await themeRes.json(); + setThemeCount(Array.isArray(themes) ? themes.length : 0); + } + if (policyRes?.ok) { + const policy = await policyRes.json(); + const restrictionCount = policy.restrictions ? Object.keys(policy.restrictions).length : 0; + const disabledGates = policy.features ? Object.values(policy.features).filter((v: unknown) => !v).length : 0; + setPolicyRuleCount(restrictionCount + disabledGates); + } + + // JMAP health check + if (configData?.jmapServerUrl) { + try { + const jmapRes = await fetch('/api/config'); + setJmapHealth(jmapRes.ok ? 'ok' : 'error'); + } catch { + setJmapHealth('error'); + } + } // Build warnings const w: string[] = []; @@ -55,6 +92,10 @@ export default function AdminDashboardPage() { if (!sessionSecret?.value || sessionSecret.value === 'your-secret-key-here') { w.push('SESSION_SECRET is not set or using a default value. Sessions are insecure.'); } + const adminPassword = sources?.adminPassword; + if (adminPassword?.value && adminPassword.source === 'env') { + w.push('ADMIN_PASSWORD is still set in environment variables. Remove it now that the hash is stored securely.'); + } } setWarnings(w); } @@ -78,7 +119,7 @@ export default function AdminDashboardPage() { } label="JMAP Server" - value={jmapUrl ? new URL(jmapUrl).hostname : '—'} + value={jmapUrl ? (() => { try { return new URL(jmapUrl).hostname; } catch { return jmapUrl; } })() : '—'} detail={jmapUrl} />
+ {/* Quick stats */} +
+ } label="Plugins" value={pluginCount} /> + } label="Themes" value={themeCount} /> + } label="Policy Rules" value={policyRuleCount} /> + } + label="JMAP Health" + value={jmapHealth === 'ok' ? 'Connected' : jmapHealth === 'error' ? 'Error' : '—'} + status={jmapHealth === 'ok' ? 'success' : jmapHealth === 'error' ? 'error' : undefined} + /> +
+ {/* Warnings */} {warnings.map((msg, i) => (
@@ -173,6 +227,19 @@ function FeaturePill({ label, active }: { label: string; active: boolean }) { ); } +function StatCard({ icon, label, value, status }: { icon: React.ReactNode; label: string; value: string | number; status?: 'success' | 'error' }) { + const statusColor = status === 'success' ? 'text-green-600 dark:text-green-400' : status === 'error' ? 'text-red-600 dark:text-red-400' : 'text-foreground'; + return ( +
+
+ {icon} + {label} +
+
{value}
+
+ ); +} + function formatDetail(detail: Record): string { if (!detail || Object.keys(detail).length === 0) return ''; if (detail.key) return `${detail.key}: ${detail.old} → ${detail.new}`; diff --git a/app/admin/plugins/page.tsx b/app/admin/plugins/page.tsx new file mode 100644 index 00000000..ace58673 --- /dev/null +++ b/app/admin/plugins/page.tsx @@ -0,0 +1,286 @@ +'use client'; + +import { useEffect, useState, useRef } from 'react'; +import { Upload, Trash2, Power, AlertTriangle, Loader2, Package, Save, Shield } from 'lucide-react'; +import type { SettingsPolicy } from '@/lib/admin/types'; +import { DEFAULT_POLICY } from '@/lib/admin/types'; + +interface PluginEntry { + id: string; + name: string; + version: string; + author: string; + description: string; + type: string; + enabled: boolean; + permissions: string[]; + installedAt: string; + updatedAt: string; +} + +export default function AdminPluginsPage() { + const [plugins, setPlugins] = useState([]); + const [loading, setLoading] = useState(true); + const [uploading, setUploading] = useState(false); + const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null); + const fileInputRef = useRef(null); + const [policy, setPolicy] = useState({ ...DEFAULT_POLICY }); + const [policyDirty, setPolicyDirty] = useState(false); + const [savingPolicy, setSavingPolicy] = useState(false); + + useEffect(() => { fetchPlugins(); fetchPolicy(); }, []); + + async function fetchPolicy() { + try { + const res = await fetch('/api/admin/policy'); + if (res.ok) { + const data = await res.json(); + setPolicy(data); + } + } catch { /* ignore */ } + } + + function togglePluginsEnabled() { + setPolicy(prev => ({ + ...prev, + features: { ...prev.features, pluginsEnabled: !prev.features.pluginsEnabled }, + })); + setPolicyDirty(true); + setMessage(null); + } + + async function handleSavePolicy() { + setSavingPolicy(true); + setMessage(null); + try { + 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: 'Plugin policy saved. Users will see changes on next login.' }); + setPolicyDirty(false); + } else { + const data = await res.json(); + setMessage({ type: 'error', text: data.error || 'Failed to save policy' }); + } + } catch { + setMessage({ type: 'error', text: 'Failed to save policy' }); + } finally { + setSavingPolicy(false); + } + } + + async function fetchPlugins() { + setLoading(true); + try { + const res = await fetch('/api/admin/plugins'); + if (res.ok) setPlugins(await res.json()); + } finally { + setLoading(false); + } + } + + async function handleUpload(e: React.ChangeEvent) { + const file = e.target.files?.[0]; + if (!file) return; + + setUploading(true); + setMessage(null); + + const formData = new FormData(); + formData.append('file', file); + + try { + const res = await fetch('/api/admin/plugins', { + method: 'POST', + body: formData, + }); + + const data = await res.json(); + if (res.ok) { + const warnings = data.warnings?.length ? ` (${data.warnings.length} warning(s))` : ''; + setMessage({ type: 'success', text: `Plugin "${data.plugin.name}" installed${warnings}` }); + await fetchPlugins(); + } else { + setMessage({ type: 'error', text: data.error || 'Upload failed' }); + } + } catch { + setMessage({ type: 'error', text: 'Upload failed' }); + } finally { + setUploading(false); + if (fileInputRef.current) fileInputRef.current.value = ''; + } + } + + async function togglePlugin(id: string, enabled: boolean) { + setMessage(null); + const res = await fetch('/api/admin/plugins', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ id, enabled }), + }); + + if (res.ok) { + setPlugins(prev => prev.map(p => p.id === id ? { ...p, enabled } : p)); + } else { + const data = await res.json(); + setMessage({ type: 'error', text: data.error || 'Update failed' }); + } + } + + async function deletePlugin(id: string, name: string) { + if (!confirm(`Remove plugin "${name}"? This cannot be undone.`)) return; + + setMessage(null); + const res = await fetch('/api/admin/plugins', { + method: 'DELETE', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ id }), + }); + + if (res.ok) { + setPlugins(prev => prev.filter(p => p.id !== id)); + setMessage({ type: 'success', text: `Plugin "${name}" removed` }); + } else { + const data = await res.json(); + setMessage({ type: 'error', text: data.error || 'Delete failed' }); + } + } + + if (loading) { + return
Loading...
; + } + + const pluginsEnabled = policy.features.pluginsEnabled ?? true; + + return ( +
+
+
+

Plugins

+

Manage plugins and plugin policy for all users

+
+
+ {policyDirty && ( + + )} + +
+
+ + {message && ( +
+ {message.text} +
+ )} + + {/* Plugin Policy */} +
+
+
+ +

Plugin Policy

+
+

Control plugin availability for users

+
+
+
+
+ Plugins Enabled +

Allow the plugin system to load and run plugins for users

+
+ +
+
+
+ + {/* Deployed Plugins */} +
+
+
+ +

Deployed Plugins

+
+

Admin-uploaded plugins for all users

+
+ {plugins.length === 0 ? ( +
+ +

No plugins installed

+

Upload a plugin ZIP file to get started

+
+ ) : ( +
+ {plugins.map(plugin => ( +
+
+
+ {plugin.name} + v{plugin.version} + + {plugin.enabled ? 'Enabled' : 'Disabled'} + +
+ {plugin.description && ( +

{plugin.description}

+ )} +
+ by {plugin.author} · {plugin.type} · installed {new Date(plugin.installedAt).toLocaleDateString()} +
+ {plugin.permissions.length > 0 && ( +
+ + + Permissions: {plugin.permissions.join(', ')} + +
+ )} +
+ +
+ + +
+
+ ))} +
+ )} +
+
+ ); +} diff --git a/app/admin/policy/page.tsx b/app/admin/policy/page.tsx index e4275296..24b1744a 100644 --- a/app/admin/policy/page.tsx +++ b/app/admin/policy/page.tsx @@ -5,9 +5,11 @@ 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 = { +// Feature gates managed on their own admin pages (excluded from this list) +const EXCLUDED_FEATURE_GATES: (keyof FeatureGates)[] = ['pluginsEnabled', 'themesEnabled', 'userThemesEnabled']; + +const FEATURE_GATE_LABELS: Partial> = { 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' }, @@ -47,9 +49,15 @@ export default function AdminPolicyPage() { async function fetchPolicy() { setLoading(true); - const res = await fetch('/api/admin/policy'); - if (res.ok) setPolicy(await res.json()); - setLoading(false); + try { + const res = await fetch('/api/admin/policy'); + if (res.ok) { + const data = await res.json(); + setPolicy(data); + } + } finally { + setLoading(false); + } } function toggleFeature(key: keyof FeatureGates) { @@ -145,11 +153,15 @@ export default function AdminPolicyPage() {

Feature Gates

-

Toggle entire features on or off for all users

+

Toggle entire features on or off for all users. Plugin and theme gates are on their respective admin pages.

- {(Object.keys(DEFAULT_FEATURE_GATES) as (keyof FeatureGates)[]).map(key => { - const { label, description } = FEATURE_GATE_LABELS[key]; + {(Object.keys(DEFAULT_FEATURE_GATES) as (keyof FeatureGates)[]) + .filter(key => !EXCLUDED_FEATURE_GATES.includes(key)) + .map(key => { + const meta = FEATURE_GATE_LABELS[key]; + if (!meta) return null; + const { label, description } = meta; const enabled = policy.features[key]; return (
@@ -158,8 +170,8 @@ export default function AdminPolicyPage() {

{description}

); diff --git a/app/admin/settings/page.tsx b/app/admin/settings/page.tsx index 4225595f..804e12ba 100644 --- a/app/admin/settings/page.tsx +++ b/app/admin/settings/page.tsx @@ -197,9 +197,9 @@ function ToggleSetting({ label, description, configKey, value, source, onChange,
{source === 'admin' && ( + )} + +
+
+ + {message && ( +
+ {message.text} +
+ )} + + {/* Theme Policy */} +
+
+
+ +

Theme Policy

+
+

Control theme availability and defaults for users

+
+ +
+ {/* Master toggle */} +
+
+ Themes Enabled +

Allow users to select and apply themes

+
+ +
+ + {/* User uploads toggle */} +
+
+ User Theme Uploads +

Allow users to upload their own theme files

+
+ +
+ + {/* Default Theme */} +
+
+
+ Default Theme +

Theme applied when users have not chosen one

+
+ +
+
+ + {/* Built-in themes */} +
+ Built-in Themes +
+ {BUILTIN_THEME_OPTIONS.map(theme => { + const disabled = (policy.themePolicy?.disabledBuiltinThemes || []).includes(theme.id); + return ( +
+ {theme.name} + +
+ ); + })} +
+
+ + {/* Admin-deployed themes */} + {themes.length > 0 && ( +
+ Admin-deployed Themes +
+ {themes.map(theme => { + const disabled = (policy.themePolicy?.disabledThemes || []).includes(theme.id); + return ( +
+ {theme.name} + +
+ ); + })} +
+
+ )} +
+
+ + {/* Deployed Themes */} +
+
+
+ +

Deployed Themes

+
+

Admin-uploaded themes available to all users

+
+ {themes.length === 0 ? ( +
+ +

No themes installed

+

Upload a theme ZIP file to get started

+
+ ) : ( +
+ {themes.map(theme => ( +
+
+
+ {theme.name} + v{theme.version} + + {theme.enabled ? 'Enabled' : 'Disabled'} + +
+ {theme.description && ( +

{theme.description}

+ )} +
+ by {theme.author} · {theme.variants.join(', ')} · installed {new Date(theme.installedAt).toLocaleDateString()} +
+
+ +
+ + +
+
+ ))} +
+ )} +
+
+ ); +} diff --git a/app/api/admin/auth/route.ts b/app/api/admin/auth/route.ts index 635d5e1e..fb3b67ab 100644 --- a/app/api/admin/auth/route.ts +++ b/app/api/admin/auth/route.ts @@ -1,5 +1,5 @@ import { NextRequest, NextResponse } from 'next/server'; -import { verifyAdminPassword, updateLastLogin, isAdminEnabled, getAdminMeta } from '@/lib/admin/password'; +import { initAdminPassword, 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'; @@ -10,6 +10,7 @@ import { logger } from '@/lib/logger'; */ export async function POST(request: NextRequest) { try { + await initAdminPassword(); if (!isAdminEnabled()) { return NextResponse.json({ error: 'Admin dashboard is not configured' }, { status: 404 }); } @@ -57,6 +58,7 @@ export async function POST(request: NextRequest) { */ export async function GET() { try { + await initAdminPassword(); if (!isAdminEnabled()) { return NextResponse.json({ enabled: false, authenticated: false }, { headers: { 'Cache-Control': 'no-store' }, diff --git a/app/api/admin/plugins/[id]/bundle/route.ts b/app/api/admin/plugins/[id]/bundle/route.ts new file mode 100644 index 00000000..e5d0c4b9 --- /dev/null +++ b/app/api/admin/plugins/[id]/bundle/route.ts @@ -0,0 +1,46 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getPluginBundle, getPlugin } from '@/lib/admin/plugin-registry'; + +/** + * GET /api/admin/plugins/[id]/bundle — Serve plugin JS bundle + * + * Public endpoint so the client-side plugin loader can fetch bundles. + * Only serves plugins that exist in the registry and are enabled. + */ +export async function GET( + _request: NextRequest, + { params }: { params: Promise<{ id: string }> }, +) { + try { + const { id } = await params; + + // Validate ID format to prevent path traversal + if (!/^[a-z0-9][a-z0-9-]*[a-z0-9]$/.test(id)) { + return NextResponse.json({ error: 'Invalid plugin ID' }, { status: 400 }); + } + + const plugin = await getPlugin(id); + if (!plugin) { + return NextResponse.json({ error: 'Plugin not found' }, { status: 404 }); + } + + if (!plugin.enabled) { + return NextResponse.json({ error: 'Plugin is disabled' }, { status: 403 }); + } + + const code = await getPluginBundle(id); + if (!code) { + return NextResponse.json({ error: 'Bundle not found' }, { status: 404 }); + } + + return new NextResponse(code, { + headers: { + 'Content-Type': 'application/javascript; charset=utf-8', + 'Cache-Control': 'public, max-age=3600, must-revalidate', + 'Content-Length': String(Buffer.byteLength(code, 'utf-8')), + }, + }); + } catch { + return NextResponse.json({ error: 'Not found' }, { status: 404 }); + } +} diff --git a/app/api/admin/plugins/route.ts b/app/api/admin/plugins/route.ts new file mode 100644 index 00000000..3887a06b --- /dev/null +++ b/app/api/admin/plugins/route.ts @@ -0,0 +1,234 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { requireAdminAuth, getClientIP } from '@/lib/admin/session'; +import { auditLog } from '@/lib/admin/audit'; +import { logger } from '@/lib/logger'; +import { + getPluginRegistry, + savePlugin, + deletePlugin as removePlugin, + type ServerPlugin, +} from '@/lib/admin/plugin-registry'; + +// Server-side extraction using the same validation logic +// ZIP parsing needs to happen on the server for admin-uploaded plugins +import JSZip from 'jszip'; +import { MAX_PLUGIN_SIZE, ALL_PERMISSIONS, ALLOWED_PLUGIN_FILES } from '@/lib/plugin-types'; + +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' }, +]; + +/** + * GET /api/admin/plugins — List all admin-managed plugins + */ +export async function GET() { + try { + const result = await requireAdminAuth(); + if ('error' in result) return result.error; + + const registry = await getPluginRegistry(); + return NextResponse.json(registry.plugins, { + headers: { 'Cache-Control': 'no-store' }, + }); + } catch (error) { + logger.error('Plugin list error', { error: error instanceof Error ? error.message : 'Unknown error' }); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} + +/** + * POST /api/admin/plugins — Upload and install a plugin ZIP + */ +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; + + if (!file) { + return NextResponse.json({ error: 'Missing file' }, { status: 400 }); + } + + if (file.size > MAX_PLUGIN_SIZE) { + return NextResponse.json({ error: 'Plugin ZIP exceeds 5 MB size limit' }, { status: 400 }); + } + + // Extract and validate ZIP + let zip: JSZip; + try { + const buffer = await file.arrayBuffer(); + zip = await JSZip.loadAsync(buffer); + } catch { + return NextResponse.json({ error: 'Invalid ZIP file' }, { status: 400 }); + } + + // Find root + const entries = Object.keys(zip.files); + const topDirs = new Set(entries.map(e => e.split('/')[0])); + let root = ''; + if (topDirs.size === 1) { + const dir = [...topDirs][0]; + if (zip.files[dir + '/'] || entries.some(e => e.startsWith(dir + '/'))) { + root = dir + '/'; + } + } + + // Read manifest + const manifestFile = zip.file(root + 'manifest.json'); + if (!manifestFile) { + return NextResponse.json({ error: 'Missing manifest.json' }, { status: 400 }); + } + + let manifest: Record; + try { + manifest = JSON.parse(await manifestFile.async('string')); + } catch { + return NextResponse.json({ error: 'Invalid manifest.json' }, { status: 400 }); + } + + // Validate manifest + 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.entrypoint || typeof manifest.entrypoint !== 'string') errors.push('Missing or invalid "entrypoint"'); + + const validTypes = ['ui-extension', 'sidebar-app', 'hook']; + if (!validTypes.includes(manifest.type as string)) { + errors.push(`Invalid type. Must be one of: ${validTypes.join(', ')}`); + } + + 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'); + } + + 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 NextResponse.json({ error: errors.join('; ') }, { status: 400 }); + } + + // Check file extensions + for (const [filePath, entry] of Object.entries(zip.files)) { + if (entry.dir) continue; + const ext = filePath.lastIndexOf('.') >= 0 ? filePath.slice(filePath.lastIndexOf('.')).toLowerCase() : ''; + if (ext && !ALLOWED_PLUGIN_FILES.has(ext)) { + errors.push(`Disallowed file type: ${filePath}`); + } + } + if (errors.length > 0) { + return NextResponse.json({ error: errors.join('; ') }, { status: 400 }); + } + + // Read entrypoint code + const entryFile = zip.file(root + (manifest.entrypoint as string)); + if (!entryFile) { + return NextResponse.json({ error: `Missing entrypoint: ${manifest.entrypoint}` }, { status: 400 }); + } + const code = await entryFile.async('string'); + + // Security warnings (logged but not blocking for admin) + const warnings: string[] = []; + for (const { pattern, label } of SUSPICIOUS_JS_PATTERNS) { + if (pattern.test(code)) warnings.push(`Contains ${label}`); + pattern.lastIndex = 0; + } + + const now = new Date().toISOString(); + const plugin: ServerPlugin = { + id: manifest.id as string, + name: manifest.name as string, + version: manifest.version as string, + author: manifest.author as string, + description: (manifest.description as string) || '', + type: manifest.type as string, + permissions: (manifest.permissions as string[]) || [], + entrypoint: manifest.entrypoint as string, + enabled: true, + installedAt: now, + updatedAt: now, + }; + + await savePlugin(plugin, code); + await auditLog('plugin.install', { id: plugin.id, name: plugin.name, version: plugin.version, warnings }, ip); + + return NextResponse.json({ plugin, warnings }); + } catch (error) { + logger.error('Plugin install error', { error: error instanceof Error ? error.message : 'Unknown error' }); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} + +/** + * PATCH /api/admin/plugins — Update plugin metadata (enable/disable) + * Body: { id: string, enabled: boolean } + */ +export async function PATCH(request: NextRequest) { + try { + const result = await requireAdminAuth(); + if ('error' in result) return result.error; + + const ip = getClientIP(request); + const { id, enabled } = await request.json(); + + if (!id || typeof id !== 'string') { + return NextResponse.json({ error: 'Missing plugin id' }, { status: 400 }); + } + if (typeof enabled !== 'boolean') { + return NextResponse.json({ error: 'enabled must be a boolean' }, { status: 400 }); + } + + const { updatePluginMeta } = await import('@/lib/admin/plugin-registry'); + const updated = await updatePluginMeta(id, { enabled }); + if (!updated) { + return NextResponse.json({ error: 'Plugin not found' }, { status: 404 }); + } + + await auditLog('plugin.update', { id, enabled }, ip); + return NextResponse.json({ plugin: updated }); + } catch (error) { + logger.error('Plugin update error', { error: error instanceof Error ? error.message : 'Unknown error' }); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} + +/** + * DELETE /api/admin/plugins — Remove a plugin + * Body: { id: string } + */ +export async function DELETE(request: NextRequest) { + try { + const result = await requireAdminAuth(); + if ('error' in result) return result.error; + + const ip = getClientIP(request); + const { id } = await request.json(); + + if (!id || typeof id !== 'string') { + return NextResponse.json({ error: 'Missing plugin id' }, { status: 400 }); + } + + const removed = await removePlugin(id); + if (!removed) { + return NextResponse.json({ error: 'Plugin not found' }, { status: 404 }); + } + + await auditLog('plugin.delete', { id }, ip); + return NextResponse.json({ success: true }); + } catch (error) { + logger.error('Plugin delete error', { error: error instanceof Error ? error.message : 'Unknown error' }); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} diff --git a/app/api/admin/policy/route.ts b/app/api/admin/policy/route.ts index d2664a8e..4a9513cc 100644 --- a/app/api/admin/policy/route.ts +++ b/app/api/admin/policy/route.ts @@ -43,6 +43,9 @@ export async function PUT(request: NextRequest) { if (policy.features && typeof policy.features !== 'object') { return NextResponse.json({ error: 'features must be an object' }, { status: 400 }); } + if (policy.themePolicy && typeof policy.themePolicy !== 'object') { + return NextResponse.json({ error: 'themePolicy must be an object' }, { status: 400 }); + } await configManager.setPolicy(policy); await auditLog('policy.update', { restrictionCount: Object.keys(policy.restrictions || {}).length }, ip); diff --git a/app/api/admin/themes/[id]/css/route.ts b/app/api/admin/themes/[id]/css/route.ts new file mode 100644 index 00000000..cf435a67 --- /dev/null +++ b/app/api/admin/themes/[id]/css/route.ts @@ -0,0 +1,46 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getThemeCSS, getThemeRegistry } from '@/lib/admin/plugin-registry'; +import { logger } from '@/lib/logger'; + +/** + * GET /api/admin/themes/[id]/css — Serve theme CSS to clients + */ +export async function GET( + _request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + const { id } = await params; + + // Validate ID format + if (!/^[a-z0-9][a-z0-9-]*[a-z0-9]$/.test(id)) { + return NextResponse.json({ error: 'Invalid theme ID' }, { status: 400 }); + } + + // Verify theme exists and is enabled + const registry = await getThemeRegistry(); + const theme = registry.themes.find(t => t.id === id); + if (!theme) { + return NextResponse.json({ error: 'Theme not found' }, { status: 404 }); + } + if (!theme.enabled) { + return NextResponse.json({ error: 'Theme is disabled' }, { status: 403 }); + } + + const css = await getThemeCSS(id); + if (!css) { + return NextResponse.json({ error: 'Theme CSS not found' }, { status: 404 }); + } + + return new NextResponse(css, { + headers: { + 'Content-Type': 'text/css; charset=utf-8', + 'Cache-Control': 'public, max-age=3600', + 'X-Content-Type-Options': 'nosniff', + }, + }); + } catch (error) { + logger.error('Theme CSS serve error', { error: error instanceof Error ? error.message : 'Unknown error' }); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} diff --git a/app/api/admin/themes/route.ts b/app/api/admin/themes/route.ts new file mode 100644 index 00000000..15a93dd7 --- /dev/null +++ b/app/api/admin/themes/route.ts @@ -0,0 +1,213 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { requireAdminAuth, getClientIP } from '@/lib/admin/session'; +import { auditLog } from '@/lib/admin/audit'; +import { logger } from '@/lib/logger'; +import { + getThemeRegistry, + saveTheme, + deleteTheme as removeTheme, + type ServerTheme, +} from '@/lib/admin/plugin-registry'; + +import JSZip from 'jszip'; +import { MAX_THEME_SIZE } from '@/lib/plugin-types'; +import { sanitizeThemeCSS, validateThemeCSSSafety } from '@/lib/theme-loader'; + +/** + * GET /api/admin/themes — List all admin-managed themes + */ +export async function GET() { + try { + const result = await requireAdminAuth(); + if ('error' in result) return result.error; + + const registry = await getThemeRegistry(); + return NextResponse.json(registry.themes, { + headers: { 'Cache-Control': 'no-store' }, + }); + } catch (error) { + logger.error('Theme list error', { error: error instanceof Error ? error.message : 'Unknown error' }); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} + +/** + * POST /api/admin/themes — Upload and install a theme ZIP + */ +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; + + if (!file) { + return NextResponse.json({ error: 'Missing file' }, { status: 400 }); + } + + if (file.size > MAX_THEME_SIZE) { + return NextResponse.json({ error: 'Theme ZIP exceeds 1 MB size limit' }, { status: 400 }); + } + + // Extract and validate ZIP + let zip: JSZip; + try { + const buffer = await file.arrayBuffer(); + zip = await JSZip.loadAsync(buffer); + } catch { + return NextResponse.json({ error: 'Invalid ZIP file' }, { status: 400 }); + } + + // Find root + const entries = Object.keys(zip.files); + const topDirs = new Set(entries.map(e => e.split('/')[0])); + let root = ''; + if (topDirs.size === 1) { + const dir = [...topDirs][0]; + if (zip.files[dir + '/'] || entries.some(e => e.startsWith(dir + '/'))) { + root = dir + '/'; + } + } + + // Read manifest + const manifestFile = zip.file(root + 'manifest.json'); + if (!manifestFile) { + return NextResponse.json({ error: 'Missing manifest.json' }, { status: 400 }); + } + + let manifest: Record; + try { + manifest = JSON.parse(await manifestFile.async('string')); + } catch { + return NextResponse.json({ error: 'Invalid manifest.json' }, { status: 400 }); + } + + // Validate manifest + 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 !== 'theme') { + errors.push(`Expected type "theme", got "${manifest.type}"`); + } + + 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'); + } + + if (!manifest.variants || !Array.isArray(manifest.variants) || manifest.variants.length === 0) { + errors.push('Missing or empty "variants" array'); + } 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 NextResponse.json({ error: errors.join('; ') }, { status: 400 }); + } + + // Read theme.css + const cssFile = zip.file(root + 'theme.css'); + if (!cssFile) { + return NextResponse.json({ error: 'Missing theme.css' }, { status: 400 }); + } + + let css = await cssFile.async('string'); + + // Validate and sanitize CSS + const warnings: string[] = []; + const safety = validateThemeCSSSafety(css); + if (!safety.valid) { + const sanitized = sanitizeThemeCSS(css); + css = sanitized.css; + warnings.push(...sanitized.warnings); + } + + const now = new Date().toISOString(); + const theme: ServerTheme = { + id: manifest.id as string, + name: manifest.name as string, + version: manifest.version as string, + author: manifest.author as string, + description: (manifest.description as string) || '', + variants: manifest.variants as string[], + enabled: true, + installedAt: now, + updatedAt: now, + }; + + await saveTheme(theme, css); + await auditLog('theme.install', { id: theme.id, name: theme.name, version: theme.version, warnings }, ip); + + return NextResponse.json({ theme, warnings }); + } catch (error) { + logger.error('Theme install error', { error: error instanceof Error ? error.message : 'Unknown error' }); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} + +/** + * PATCH /api/admin/themes — Update theme metadata (enable/disable) + * Body: { id: string, enabled: boolean } + */ +export async function PATCH(request: NextRequest) { + try { + const result = await requireAdminAuth(); + if ('error' in result) return result.error; + + const ip = getClientIP(request); + const { id, enabled } = await request.json(); + + if (!id || typeof id !== 'string') { + return NextResponse.json({ error: 'Missing theme id' }, { status: 400 }); + } + if (typeof enabled !== 'boolean') { + return NextResponse.json({ error: 'enabled must be a boolean' }, { status: 400 }); + } + + const { updateThemeMeta } = await import('@/lib/admin/plugin-registry'); + const updated = await updateThemeMeta(id, { enabled }); + if (!updated) { + return NextResponse.json({ error: 'Theme not found' }, { status: 404 }); + } + + await auditLog('theme.update', { id, enabled }, ip); + return NextResponse.json({ theme: updated }); + } catch (error) { + logger.error('Theme update error', { error: error instanceof Error ? error.message : 'Unknown error' }); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} + +/** + * DELETE /api/admin/themes — Remove a theme + * Body: { id: string } + */ +export async function DELETE(request: NextRequest) { + try { + const result = await requireAdminAuth(); + if ('error' in result) return result.error; + + const ip = getClientIP(request); + const { id } = await request.json(); + + if (!id || typeof id !== 'string') { + return NextResponse.json({ error: 'Missing theme id' }, { status: 400 }); + } + + const removed = await removeTheme(id); + if (!removed) { + return NextResponse.json({ error: 'Theme not found' }, { status: 404 }); + } + + await auditLog('theme.delete', { id }, ip); + return NextResponse.json({ success: true }); + } catch (error) { + logger.error('Theme delete error', { error: error instanceof Error ? error.message : 'Unknown error' }); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} diff --git a/components/settings/advanced-settings.tsx b/components/settings/advanced-settings.tsx index 994b515e..0dd634c8 100644 --- a/components/settings/advanced-settings.tsx +++ b/components/settings/advanced-settings.tsx @@ -6,6 +6,7 @@ import { useSettingsStore } from '@/stores/settings-store'; import { useConfig } from '@/hooks/use-config'; import { SettingsSection, SettingItem, ToggleSwitch } from './settings-section'; import { Button } from '@/components/ui/button'; +import { usePolicyStore } from '@/stores/policy-store'; export function AdvancedSettings() { const t = useTranslations('settings.advanced'); @@ -15,6 +16,7 @@ export function AdvancedSettings() { const { settingsSyncEnabled } = useConfig(); const [showResetConfirm, setShowResetConfirm] = useState(false); const fileInputRef = useRef(null); + const { isSettingLocked, isSettingHidden, isFeatureEnabled } = usePolicyStore(); const handleExport = () => { const settingsJson = exportSettings(); @@ -64,9 +66,11 @@ export function AdvancedSettings() { return ( {/* Debug Mode */} - + {!isSettingHidden('debugMode') && isFeatureEnabled('debugModeEnabled') && ( + updateSetting('debugMode', checked)} /> + )} {/* Settings Sync */} {settingsSyncEnabled && ( @@ -81,13 +85,16 @@ export function AdvancedSettings() { {/* Export Settings */} + {isFeatureEnabled('settingsExportEnabled') && ( + )} {/* Import Settings */} + {isFeatureEnabled('settingsExportEnabled') && ( <> + )} {/* Reset Settings */} diff --git a/components/settings/appearance-settings.tsx b/components/settings/appearance-settings.tsx index f046d6c9..ead997af 100644 --- a/components/settings/appearance-settings.tsx +++ b/components/settings/appearance-settings.tsx @@ -9,6 +9,7 @@ import { cn } from '@/lib/utils'; import { useTour } from '@/components/tour/tour-provider'; import { Button } from '@/components/ui/button'; import { PlayCircle } from 'lucide-react'; +import { usePolicyStore } from '@/stores/policy-store'; const DENSITY_PREVIEW: Record = { 'extra-compact': { py: 'py-0.5', gap: 'gap-1.5', showAvatar: false, showPreview: false }, @@ -68,6 +69,7 @@ export function AppearanceSettings() { const { theme, setTheme } = useThemeStore(); const { fontSize, density, animationsEnabled, toolbarPosition, showToolbarLabels, updateSetting } = useSettingsStore(); const { startTour, resetTourCompletion } = useTour(); + const { isSettingLocked, isSettingHidden } = usePolicyStore(); return ( @@ -90,7 +92,8 @@ export function AppearanceSettings() { {/* Font Size */} - + {!isSettingHidden('fontSize') && ( + updateSetting('fontSize', value as 'small' | 'medium' | 'large')} @@ -101,9 +104,11 @@ export function AppearanceSettings() { ]} /> + )} {/* Density */} - + {!isSettingHidden('density') && ( + @@ -118,6 +123,7 @@ export function AppearanceSettings() { /> + )} {/* Toolbar Position */} @@ -140,12 +146,14 @@ export function AppearanceSettings() { {/* Animations */} - + {!isSettingHidden('animationsEnabled') && ( + updateSetting('animationsEnabled', checked)} /> + )} {/* Restart Tour */} diff --git a/components/settings/email-settings.tsx b/components/settings/email-settings.tsx index 724b0d99..5bb5cba7 100644 --- a/components/settings/email-settings.tsx +++ b/components/settings/email-settings.tsx @@ -12,6 +12,7 @@ import { cn } from '@/lib/utils'; import { SettingsSection, SettingItem, Select, ToggleSwitch } from './settings-section'; import { TrustedSendersModal } from '@/components/trusted-senders-modal'; import { ChevronRight, AlertTriangle, FolderSync, Loader2, Mail } from 'lucide-react'; +import { usePolicyStore } from '@/stores/policy-store'; export function EmailSettings() { const t = useTranslations('settings.email_behavior'); @@ -20,6 +21,7 @@ export function EmailSettings() { const [isReorganizing, setIsReorganizing] = useState(false); const [reorganizeResult, setReorganizeResult] = useState(null); const [defaultMailStatus, setDefaultMailStatus] = useState<'idle' | 'success' | 'error'>('idle'); + const { isSettingLocked, isSettingHidden, isFeatureEnabled } = usePolicyStore(); const handleSetDefaultMailProgram = useCallback(() => { try { @@ -122,7 +124,8 @@ export function EmailSettings() { return ( {/* Mark as Read */} - + {!isSettingHidden('markAsReadDelay') && ( + + )} {/* Archive Mode */} @@ -198,11 +204,14 @@ export function EmailSettings() { {/* Show Preview */} - + {!isSettingHidden('showPreview') && ( + updateSetting('showPreview', checked)} /> + )} {/* Quick Hover Actions */} + {isFeatureEnabled('hoverActionsConfigEnabled') && (
@@ -234,6 +243,7 @@ export function EmailSettings() { })}
+ )} updateSetting('emailsPerPage', parseInt(value))} @@ -270,6 +281,7 @@ export function EmailSettings() { ]} /> + )} {/* Always Light Mode for Emails */} @@ -280,7 +292,8 @@ export function EmailSettings() { {/* External Content */} - + {!isSettingHidden('externalContentPolicy') && ( + + )}
); } @@ -110,12 +133,13 @@ interface ThemeCardProps { preview?: string; isActive: boolean; isBuiltIn: boolean; + isDefault?: boolean; variants?: ('light' | 'dark')[]; onActivate: () => void; onRemove?: () => void; } -function ThemeCard({ name, author, preview, isActive, variants, onActivate, onRemove }: ThemeCardProps) { +function ThemeCard({ name, author, preview, isActive, isDefault, variants, onActivate, onRemove }: ThemeCardProps) { return (