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 */}
+
+
+
+
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' && (