feat: add plugin/theme disable gates and move policy controls to their admin pages

- Add `pluginsEnabled` and `themesEnabled` master feature gates to FeatureGates
- Move theme policy UI (default theme, built-in/admin theme toggles, user uploads toggle) from policy page to themes admin page
- Add plugin policy UI (plugins enabled toggle) to plugins admin page
- Remove theme policy section and plugin/theme gates from policy page (with note directing to respective pages)
- Hide Themes and Plugins settings tabs when their feature gate is disabled
- Fix dark mode visibility of all admin toggle switches (bg-white → bg-background, increase off-state track opacity)
This commit is contained in:
Linus Rath
2026-03-25 00:44:04 +01:00
parent 76b21147e4
commit 29a222eef4
24 changed files with 1729 additions and 42 deletions
+2 -2
View File
@@ -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' },
];
+2 -2
View File
@@ -182,8 +182,8 @@ function Toggle({ label, description, configKey, value, source, onChange, onReve
</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]'}`} />
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${value ? 'bg-primary' : 'bg-muted-foreground/25 dark:bg-muted-foreground/50'}`}>
<span className={`inline-block h-3.5 w-3.5 transform rounded-full bg-background 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>
+4
View File
@@ -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 },
];
+71 -4
View File
@@ -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<ConfigData | null>(null);
const [, setConfigSources] = useState<Record<string, { value: unknown; source: string }> | null>(null);
const [warnings, setWarnings] = useState<string[]>([]);
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() {
<StatusCard
icon={<Globe className="w-4 h-4" />}
label="JMAP Server"
value={jmapUrl ? new URL(jmapUrl).hostname : '—'}
value={jmapUrl ? (() => { try { return new URL(jmapUrl).hostname; } catch { return jmapUrl; } })() : '—'}
detail={jmapUrl}
/>
<StatusCard
@@ -96,6 +137,19 @@ export default function AdminDashboardPage() {
<FeaturePill label="Stalwart" active={config?.stalwartFeaturesEnabled !== false} />
</div>
{/* Quick stats */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
<StatCard icon={<Package className="w-4 h-4" />} label="Plugins" value={pluginCount} />
<StatCard icon={<Palette className="w-4 h-4" />} label="Themes" value={themeCount} />
<StatCard icon={<Shield className="w-4 h-4" />} label="Policy Rules" value={policyRuleCount} />
<StatCard
icon={<Activity className="w-4 h-4" />}
label="JMAP Health"
value={jmapHealth === 'ok' ? 'Connected' : jmapHealth === 'error' ? 'Error' : '—'}
status={jmapHealth === 'ok' ? 'success' : jmapHealth === 'error' ? 'error' : undefined}
/>
</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">
@@ -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 (
<div className="border border-border rounded-md px-3 py-2 bg-secondary/20">
<div className="flex items-center gap-2 text-muted-foreground mb-1">
{icon}
<span className="text-xs font-medium uppercase tracking-wider">{label}</span>
</div>
<div className={`text-sm font-semibold ${statusColor}`}>{value}</div>
</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}`;
+286
View File
@@ -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<PluginEntry[]>([]);
const [loading, setLoading] = useState(true);
const [uploading, setUploading] = useState(false);
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const [policy, setPolicy] = useState<SettingsPolicy>({ ...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<HTMLInputElement>) {
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 <div className="flex items-center justify-center py-12 text-muted-foreground text-sm">Loading...</div>;
}
const pluginsEnabled = policy.features.pluginsEnabled ?? true;
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-semibold text-foreground">Plugins</h1>
<p className="text-sm text-muted-foreground mt-1">Manage plugins and plugin policy for all users</p>
</div>
<div className="flex items-center gap-2">
{policyDirty && (
<button
onClick={handleSavePolicy}
disabled={savingPolicy}
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"
>
{savingPolicy ? <Loader2 className="w-4 h-4 animate-spin" /> : <Save className="w-4 h-4" />}
Save Policy
</button>
)}
<label 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 cursor-pointer transition-all shadow-sm">
{uploading ? <Loader2 className="w-4 h-4 animate-spin" /> : <Upload className="w-4 h-4" />}
Upload Plugin
<input
ref={fileInputRef}
type="file"
accept=".zip"
onChange={handleUpload}
disabled={uploading}
className="sr-only"
/>
</label>
</div>
</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>
)}
{/* Plugin Policy */}
<div className="border border-border rounded-lg">
<div className="px-4 py-3 border-b border-border bg-muted/30">
<div className="flex items-center gap-2">
<Shield className="w-4 h-4 text-muted-foreground" />
<h2 className="text-sm font-medium text-foreground">Plugin Policy</h2>
</div>
<p className="text-xs text-muted-foreground mt-0.5">Control plugin availability for users</p>
</div>
<div className="divide-y divide-border">
<div className="px-4 py-3 flex items-center justify-between gap-4">
<div>
<span className="text-sm text-foreground">Plugins Enabled</span>
<p className="text-xs text-muted-foreground mt-0.5">Allow the plugin system to load and run plugins for users</p>
</div>
<button onClick={togglePluginsEnabled}
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${pluginsEnabled ? 'bg-primary' : 'bg-muted-foreground/25 dark:bg-muted-foreground/50'}`}>
<span className={`inline-block h-3.5 w-3.5 transform rounded-full bg-background shadow transition-transform ${pluginsEnabled ? 'translate-x-[18px]' : 'translate-x-[3px]'}`} />
</button>
</div>
</div>
</div>
{/* Deployed Plugins */}
<div className="border border-border rounded-lg">
<div className="px-4 py-3 border-b border-border bg-muted/30">
<div className="flex items-center gap-2">
<Package className="w-4 h-4 text-muted-foreground" />
<h2 className="text-sm font-medium text-foreground">Deployed Plugins</h2>
</div>
<p className="text-xs text-muted-foreground mt-0.5">Admin-uploaded plugins for all users</p>
</div>
{plugins.length === 0 ? (
<div className="p-12 text-center">
<Package className="w-10 h-10 text-muted-foreground/40 mx-auto mb-3" />
<p className="text-sm text-muted-foreground">No plugins installed</p>
<p className="text-xs text-muted-foreground mt-1">Upload a plugin ZIP file to get started</p>
</div>
) : (
<div className="divide-y divide-border">
{plugins.map(plugin => (
<div key={plugin.id} className="px-4 py-4 flex items-center justify-between gap-4">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-foreground">{plugin.name}</span>
<span className="text-xs text-muted-foreground">v{plugin.version}</span>
<span className={`text-xs px-1.5 py-0.5 rounded ${plugin.enabled ? 'bg-emerald-100 text-emerald-700 dark:bg-emerald-950/30 dark:text-emerald-400' : 'bg-muted text-muted-foreground'}`}>
{plugin.enabled ? 'Enabled' : 'Disabled'}
</span>
</div>
{plugin.description && (
<p className="text-xs text-muted-foreground mt-0.5 truncate">{plugin.description}</p>
)}
<div className="text-xs text-muted-foreground mt-1">
by {plugin.author} &middot; {plugin.type} &middot; installed {new Date(plugin.installedAt).toLocaleDateString()}
</div>
{plugin.permissions.length > 0 && (
<div className="flex items-center gap-1 mt-1">
<AlertTriangle className="w-3 h-3 text-amber-500" />
<span className="text-xs text-amber-600 dark:text-amber-400">
Permissions: {plugin.permissions.join(', ')}
</span>
</div>
)}
</div>
<div className="flex items-center gap-2">
<button
onClick={() => togglePlugin(plugin.id, !plugin.enabled)}
title={plugin.enabled ? 'Disable' : 'Enable'}
className="p-2 rounded-md hover:bg-accent text-muted-foreground hover:text-foreground transition-colors"
>
<Power className="w-4 h-4" />
</button>
<button
onClick={() => deletePlugin(plugin.id, plugin.name)}
title="Remove"
className="p-2 rounded-md hover:bg-destructive/10 text-muted-foreground hover:text-destructive transition-colors"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</div>
))}
</div>
)}
</div>
</div>
);
}
+22 -10
View File
@@ -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<keyof FeatureGates, { label: string; description: string }> = {
// 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<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' },
@@ -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() {
<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>
<p className="text-xs text-muted-foreground mt-0.5">Toggle entire features on or off for all users. Plugin and theme gates are on their respective admin pages.</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];
{(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 (
<div key={key} className="px-4 py-3 flex items-center justify-between gap-4">
@@ -158,8 +170,8 @@ export default function AdminPolicyPage() {
<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]'}`} />
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${enabled ? 'bg-primary' : 'bg-muted-foreground/25 dark:bg-muted-foreground/50'}`}>
<span className={`inline-block h-3.5 w-3.5 transform rounded-full bg-background shadow transition-transform ${enabled ? 'translate-x-[18px]' : 'translate-x-[3px]'}`} />
</button>
</div>
);
+2 -2
View File
@@ -197,9 +197,9 @@ function ToggleSetting({ label, description, configKey, value, source, onChange,
<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'}`}
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${value ? 'bg-primary' : 'bg-muted-foreground/25 dark:bg-muted-foreground/50'}`}
>
<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]'}`} />
<span className={`inline-block h-3.5 w-3.5 transform rounded-full bg-background 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">
+434
View File
@@ -0,0 +1,434 @@
'use client';
import { useEffect, useState, useRef } from 'react';
import { Upload, Trash2, Power, Loader2, Palette, Save, Shield } from 'lucide-react';
import type { SettingsPolicy } from '@/lib/admin/types';
import { DEFAULT_POLICY, DEFAULT_THEME_POLICY, DEFAULT_FEATURE_GATES } from '@/lib/admin/types';
const BUILTIN_THEME_OPTIONS = [
{ id: 'builtin-nord', name: 'Nord' },
{ id: 'builtin-catppuccin', name: 'Catppuccin' },
{ id: 'builtin-solarized', name: 'Solarized' },
];
interface ThemeEntry {
id: string;
name: string;
version: string;
author: string;
description: string;
variants: string[];
enabled: boolean;
installedAt: string;
updatedAt: string;
}
export default function AdminThemesPage() {
const [themes, setThemes] = useState<ThemeEntry[]>([]);
const [loading, setLoading] = useState(true);
const [uploading, setUploading] = useState(false);
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const [policy, setPolicy] = useState<SettingsPolicy>({ ...DEFAULT_POLICY });
const [policyDirty, setPolicyDirty] = useState(false);
const [savingPolicy, setSavingPolicy] = useState(false);
useEffect(() => { fetchThemes(); fetchPolicy(); }, []);
async function fetchPolicy() {
try {
const res = await fetch('/api/admin/policy');
if (res.ok) {
const data = await res.json();
setPolicy({
...data,
themePolicy: { ...DEFAULT_THEME_POLICY, ...(data.themePolicy || {}) },
});
}
} catch { /* ignore */ }
}
function toggleThemesEnabled() {
setPolicy(prev => ({
...prev,
features: { ...prev.features, themesEnabled: !prev.features.themesEnabled },
}));
setPolicyDirty(true);
setMessage(null);
}
function toggleUserThemeUploads() {
setPolicy(prev => ({
...prev,
features: { ...prev.features, userThemesEnabled: !prev.features.userThemesEnabled },
}));
setPolicyDirty(true);
setMessage(null);
}
function toggleBuiltinTheme(themeId: string) {
setPolicy(prev => {
const disabled = prev.themePolicy?.disabledBuiltinThemes || [];
const isDisabled = disabled.includes(themeId);
return {
...prev,
themePolicy: {
...DEFAULT_THEME_POLICY,
...prev.themePolicy,
disabledBuiltinThemes: isDisabled
? disabled.filter((id: string) => id !== themeId)
: [...disabled, themeId],
},
};
});
setPolicyDirty(true);
setMessage(null);
}
function toggleAdminTheme(themeId: string) {
setPolicy(prev => {
const disabled = prev.themePolicy?.disabledThemes || [];
const isDisabled = disabled.includes(themeId);
return {
...prev,
themePolicy: {
...DEFAULT_THEME_POLICY,
...prev.themePolicy,
disabledThemes: isDisabled
? disabled.filter((id: string) => id !== themeId)
: [...disabled, themeId],
},
};
});
setPolicyDirty(true);
setMessage(null);
}
function setDefaultTheme(themeId: string | null) {
setPolicy(prev => ({
...prev,
themePolicy: {
...DEFAULT_THEME_POLICY,
...prev.themePolicy,
defaultThemeId: themeId,
},
}));
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: 'Theme 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 fetchThemes() {
setLoading(true);
try {
const res = await fetch('/api/admin/themes');
if (res.ok) setThemes(await res.json());
} finally {
setLoading(false);
}
}
async function handleUpload(e: React.ChangeEvent<HTMLInputElement>) {
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/themes', {
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: `Theme "${data.theme.name}" installed${warnings}` });
await fetchThemes();
} 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 toggleTheme(id: string, enabled: boolean) {
setMessage(null);
const res = await fetch('/api/admin/themes', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id, enabled }),
});
if (res.ok) {
setThemes(prev => prev.map(t => t.id === id ? { ...t, enabled } : t));
} else {
const data = await res.json();
setMessage({ type: 'error', text: data.error || 'Update failed' });
}
}
async function deleteTheme(id: string, name: string) {
if (!confirm(`Remove theme "${name}"? This cannot be undone.`)) return;
setMessage(null);
const res = await fetch('/api/admin/themes', {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id }),
});
if (res.ok) {
setThemes(prev => prev.filter(t => t.id !== id));
setMessage({ type: 'success', text: `Theme "${name}" removed` });
} else {
const data = await res.json();
setMessage({ type: 'error', text: data.error || 'Delete failed' });
}
}
if (loading) {
return <div className="flex items-center justify-center py-12 text-muted-foreground text-sm">Loading...</div>;
}
const themesEnabled = policy.features.themesEnabled ?? true;
const userThemesEnabled = policy.features.userThemesEnabled ?? true;
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-semibold text-foreground">Themes</h1>
<p className="text-sm text-muted-foreground mt-1">Manage themes and theme policy for all users</p>
</div>
<div className="flex items-center gap-2">
{policyDirty && (
<button
onClick={handleSavePolicy}
disabled={savingPolicy}
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"
>
{savingPolicy ? <Loader2 className="w-4 h-4 animate-spin" /> : <Save className="w-4 h-4" />}
Save Policy
</button>
)}
<label 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 cursor-pointer transition-all shadow-sm">
{uploading ? <Loader2 className="w-4 h-4 animate-spin" /> : <Upload className="w-4 h-4" />}
Upload Theme
<input
ref={fileInputRef}
type="file"
accept=".zip"
onChange={handleUpload}
disabled={uploading}
className="sr-only"
/>
</label>
</div>
</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>
)}
{/* Theme Policy */}
<div className="border border-border rounded-lg">
<div className="px-4 py-3 border-b border-border bg-muted/30">
<div className="flex items-center gap-2">
<Shield className="w-4 h-4 text-muted-foreground" />
<h2 className="text-sm font-medium text-foreground">Theme Policy</h2>
</div>
<p className="text-xs text-muted-foreground mt-0.5">Control theme availability and defaults for users</p>
</div>
<div className="divide-y divide-border">
{/* Master toggle */}
<div className="px-4 py-3 flex items-center justify-between gap-4">
<div>
<span className="text-sm text-foreground">Themes Enabled</span>
<p className="text-xs text-muted-foreground mt-0.5">Allow users to select and apply themes</p>
</div>
<button onClick={toggleThemesEnabled}
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${themesEnabled ? 'bg-primary' : 'bg-muted-foreground/25 dark:bg-muted-foreground/50'}`}>
<span className={`inline-block h-3.5 w-3.5 transform rounded-full bg-background shadow transition-transform ${themesEnabled ? 'translate-x-[18px]' : 'translate-x-[3px]'}`} />
</button>
</div>
{/* User uploads toggle */}
<div className="px-4 py-3 flex items-center justify-between gap-4">
<div>
<span className="text-sm text-foreground">User Theme Uploads</span>
<p className="text-xs text-muted-foreground mt-0.5">Allow users to upload their own theme files</p>
</div>
<button onClick={toggleUserThemeUploads}
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${userThemesEnabled ? 'bg-primary' : 'bg-muted-foreground/25 dark:bg-muted-foreground/50'}`}>
<span className={`inline-block h-3.5 w-3.5 transform rounded-full bg-background shadow transition-transform ${userThemesEnabled ? 'translate-x-[18px]' : 'translate-x-[3px]'}`} />
</button>
</div>
{/* Default Theme */}
<div className="px-4 py-3">
<div className="flex items-center justify-between gap-4">
<div>
<span className="text-sm text-foreground">Default Theme</span>
<p className="text-xs text-muted-foreground mt-0.5">Theme applied when users have not chosen one</p>
</div>
<select
value={policy.themePolicy?.defaultThemeId || ''}
onChange={(e) => setDefaultTheme(e.target.value || null)}
className="h-8 px-2 rounded-md border border-input bg-background text-sm text-foreground"
>
<option value="">System Default</option>
<optgroup label="Built-in">
{BUILTIN_THEME_OPTIONS
.filter(t => !(policy.themePolicy?.disabledBuiltinThemes || []).includes(t.id))
.map(t => (
<option key={t.id} value={t.id}>{t.name}</option>
))}
</optgroup>
{themes.length > 0 && (
<optgroup label="Admin-deployed">
{themes
.filter(t => !(policy.themePolicy?.disabledThemes || []).includes(t.id))
.map(t => (
<option key={t.id} value={t.id}>{t.name}</option>
))}
</optgroup>
)}
</select>
</div>
</div>
{/* Built-in themes */}
<div className="px-4 py-3">
<span className="text-xs font-medium uppercase tracking-wider text-muted-foreground">Built-in Themes</span>
<div className="mt-2 space-y-2">
{BUILTIN_THEME_OPTIONS.map(theme => {
const disabled = (policy.themePolicy?.disabledBuiltinThemes || []).includes(theme.id);
return (
<div key={theme.id} className="flex items-center justify-between gap-4">
<span className="text-sm text-foreground">{theme.name}</span>
<button onClick={() => toggleBuiltinTheme(theme.id)}
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${!disabled ? 'bg-primary' : 'bg-muted-foreground/25 dark:bg-muted-foreground/50'}`}>
<span className={`inline-block h-3.5 w-3.5 transform rounded-full bg-background shadow transition-transform ${!disabled ? 'translate-x-[18px]' : 'translate-x-[3px]'}`} />
</button>
</div>
);
})}
</div>
</div>
{/* Admin-deployed themes */}
{themes.length > 0 && (
<div className="px-4 py-3">
<span className="text-xs font-medium uppercase tracking-wider text-muted-foreground">Admin-deployed Themes</span>
<div className="mt-2 space-y-2">
{themes.map(theme => {
const disabled = (policy.themePolicy?.disabledThemes || []).includes(theme.id);
return (
<div key={theme.id} className="flex items-center justify-between gap-4">
<span className="text-sm text-foreground">{theme.name}</span>
<button onClick={() => toggleAdminTheme(theme.id)}
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${!disabled ? 'bg-primary' : 'bg-muted-foreground/25 dark:bg-muted-foreground/50'}`}>
<span className={`inline-block h-3.5 w-3.5 transform rounded-full bg-background shadow transition-transform ${!disabled ? 'translate-x-[18px]' : 'translate-x-[3px]'}`} />
</button>
</div>
);
})}
</div>
</div>
)}
</div>
</div>
{/* Deployed Themes */}
<div className="border border-border rounded-lg">
<div className="px-4 py-3 border-b border-border bg-muted/30">
<div className="flex items-center gap-2">
<Palette className="w-4 h-4 text-muted-foreground" />
<h2 className="text-sm font-medium text-foreground">Deployed Themes</h2>
</div>
<p className="text-xs text-muted-foreground mt-0.5">Admin-uploaded themes available to all users</p>
</div>
{themes.length === 0 ? (
<div className="p-12 text-center">
<Palette className="w-10 h-10 text-muted-foreground/40 mx-auto mb-3" />
<p className="text-sm text-muted-foreground">No themes installed</p>
<p className="text-xs text-muted-foreground mt-1">Upload a theme ZIP file to get started</p>
</div>
) : (
<div className="divide-y divide-border">
{themes.map(theme => (
<div key={theme.id} className="px-4 py-4 flex items-center justify-between gap-4">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-foreground">{theme.name}</span>
<span className="text-xs text-muted-foreground">v{theme.version}</span>
<span className={`text-xs px-1.5 py-0.5 rounded ${theme.enabled ? 'bg-emerald-100 text-emerald-700 dark:bg-emerald-950/30 dark:text-emerald-400' : 'bg-muted text-muted-foreground'}`}>
{theme.enabled ? 'Enabled' : 'Disabled'}
</span>
</div>
{theme.description && (
<p className="text-xs text-muted-foreground mt-0.5 truncate">{theme.description}</p>
)}
<div className="text-xs text-muted-foreground mt-1">
by {theme.author} &middot; {theme.variants.join(', ')} &middot; installed {new Date(theme.installedAt).toLocaleDateString()}
</div>
</div>
<div className="flex items-center gap-2">
<button
onClick={() => toggleTheme(theme.id, !theme.enabled)}
title={theme.enabled ? 'Disable' : 'Enable'}
className="p-2 rounded-md hover:bg-accent text-muted-foreground hover:text-foreground transition-colors"
>
<Power className="w-4 h-4" />
</button>
<button
onClick={() => deleteTheme(theme.id, theme.name)}
title="Remove"
className="p-2 rounded-md hover:bg-destructive/10 text-muted-foreground hover:text-destructive transition-colors"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</div>
))}
</div>
)}
</div>
</div>
);
}
+3 -1
View File
@@ -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' },
@@ -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 });
}
}
+234
View File
@@ -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<string, unknown>;
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 });
}
}
+3
View File
@@ -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);
+46
View File
@@ -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 });
}
}
+213
View File
@@ -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<string, unknown>;
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 });
}
}
+9 -1
View File
@@ -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<HTMLInputElement>(null);
const { isSettingLocked, isSettingHidden, isFeatureEnabled } = usePolicyStore();
const handleExport = () => {
const settingsJson = exportSettings();
@@ -64,9 +66,11 @@ export function AdvancedSettings() {
return (
<SettingsSection title={t('title')} description={t('description')}>
{/* Debug Mode */}
<SettingItem label={t('debug_mode.label')} description={t('debug_mode.description')}>
{!isSettingHidden('debugMode') && isFeatureEnabled('debugModeEnabled') && (
<SettingItem label={t('debug_mode.label')} description={t('debug_mode.description')} locked={isSettingLocked('debugMode')}>
<ToggleSwitch checked={debugMode} onChange={(checked) => updateSetting('debugMode', checked)} />
</SettingItem>
)}
{/* Settings Sync */}
{settingsSyncEnabled && (
@@ -81,13 +85,16 @@ export function AdvancedSettings() {
</SettingItem>
{/* Export Settings */}
{isFeatureEnabled('settingsExportEnabled') && (
<SettingItem label={t('export_settings.label')} description={t('export_settings.description')}>
<Button variant="outline" size="sm" onClick={handleExport}>
{t('export_settings.button')}
</Button>
</SettingItem>
)}
{/* Import Settings */}
{isFeatureEnabled('settingsExportEnabled') && (
<SettingItem label={t('import_settings.label')} description={t('import_settings.description')}>
<>
<input
@@ -102,6 +109,7 @@ export function AdvancedSettings() {
</Button>
</>
</SettingItem>
)}
{/* Reset Settings */}
<SettingItem label={t('reset_settings.label')} description={t('reset_settings.description')}>
+11 -3
View File
@@ -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<Density, { py: string; gap: string; showAvatar: boolean; showPreview: boolean }> = {
'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 (
<SettingsSection title={t('title')} description={t('description')}>
@@ -90,7 +92,8 @@ export function AppearanceSettings() {
</SettingItem>
{/* Font Size */}
<SettingItem label={t('font_size.label')} description={t('font_size.description')}>
{!isSettingHidden('fontSize') && (
<SettingItem label={t('font_size.label')} description={t('font_size.description')} locked={isSettingLocked('fontSize')}>
<RadioGroup
value={fontSize}
onChange={(value) => updateSetting('fontSize', value as 'small' | 'medium' | 'large')}
@@ -101,9 +104,11 @@ export function AppearanceSettings() {
]}
/>
</SettingItem>
)}
{/* Density */}
<SettingItem label={t('list_density.label')} description={t('list_density.description')}>
{!isSettingHidden('density') && (
<SettingItem label={t('list_density.label')} description={t('list_density.description')} locked={isSettingLocked('density')}>
<RadioGroup
value={density}
onChange={(value) =>
@@ -118,6 +123,7 @@ export function AppearanceSettings() {
/>
<DensityPreview density={density} />
</SettingItem>
)}
{/* Toolbar Position */}
<SettingItem label={t('toolbar_position.label')} description={t('toolbar_position.description')}>
@@ -140,12 +146,14 @@ export function AppearanceSettings() {
</SettingItem>
{/* Animations */}
<SettingItem label={t('animations.label')} description={t('animations.description')}>
{!isSettingHidden('animationsEnabled') && (
<SettingItem label={t('animations.label')} description={t('animations.description')} locked={isSettingLocked('animationsEnabled')}>
<ToggleSwitch
checked={animationsEnabled}
onChange={(checked) => updateSetting('animationsEnabled', checked)}
/>
</SettingItem>
)}
{/* Restart Tour */}
<SettingItem label={tTour('restart_title')} description={tTour('restart_desc')}>
+19 -5
View File
@@ -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<string | null>(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 (
<SettingsSection title={t('title')} description={t('description')}>
{/* Mark as Read */}
<SettingItem label={t('mark_read.label')} description={t('mark_read.description')}>
{!isSettingHidden('markAsReadDelay') && (
<SettingItem label={t('mark_read.label')} description={t('mark_read.description')} locked={isSettingLocked('markAsReadDelay')}>
<Select
value={markAsReadDelay.toString()}
onChange={(value) => updateSetting('markAsReadDelay', parseInt(value))}
@@ -134,9 +137,11 @@ export function EmailSettings() {
]}
/>
</SettingItem>
)}
{/* Delete Action */}
<SettingItem label={t('delete_action.label')} description={t('delete_action.description')}>
{!isSettingHidden('deleteAction') && (
<SettingItem label={t('delete_action.label')} description={t('delete_action.description')} locked={isSettingLocked('deleteAction')}>
<div className="flex flex-col gap-2">
<Select
value={deleteAction}
@@ -154,6 +159,7 @@ export function EmailSettings() {
)}
</div>
</SettingItem>
)}
{/* Archive Mode */}
<SettingItem label={t('archive_mode.label')} description={t('archive_mode.description')}>
@@ -198,11 +204,14 @@ export function EmailSettings() {
</SettingItem>
{/* Show Preview */}
<SettingItem label={t('show_preview.label')} description={t('show_preview.description')}>
{!isSettingHidden('showPreview') && (
<SettingItem label={t('show_preview.label')} description={t('show_preview.description')} locked={isSettingLocked('showPreview')}>
<ToggleSwitch checked={showPreview} onChange={(checked) => updateSetting('showPreview', checked)} />
</SettingItem>
)}
{/* Quick Hover Actions */}
{isFeatureEnabled('hoverActionsConfigEnabled') && (
<div className="py-3 border-b border-border space-y-3">
<div>
<label className="text-sm font-medium text-foreground">{t('hover_actions.label')}</label>
@@ -234,6 +243,7 @@ export function EmailSettings() {
})}
</div>
</div>
)}
<SettingItem label={t('attachment_click_action.label')} description={t('attachment_click_action.description')}>
<Select
@@ -258,7 +268,8 @@ export function EmailSettings() {
</SettingItem>
{/* Emails Per Page */}
<SettingItem label={t('emails_per_page.label')} description={t('emails_per_page.description')}>
{!isSettingHidden('emailsPerPage') && (
<SettingItem label={t('emails_per_page.label')} description={t('emails_per_page.description')} locked={isSettingLocked('emailsPerPage')}>
<Select
value={emailsPerPage.toString()}
onChange={(value) => updateSetting('emailsPerPage', parseInt(value))}
@@ -270,6 +281,7 @@ export function EmailSettings() {
]}
/>
</SettingItem>
)}
{/* Always Light Mode for Emails */}
<SettingItem label={t('always_light_mode.label')} description={t('always_light_mode.description')}>
@@ -280,7 +292,8 @@ export function EmailSettings() {
</SettingItem>
{/* External Content */}
<SettingItem label={t('external_content.label')} description={t('external_content.description')}>
{!isSettingHidden('externalContentPolicy') && (
<SettingItem label={t('external_content.label')} description={t('external_content.description')} locked={isSettingLocked('externalContentPolicy')}>
<Select
value={externalContentPolicy}
onChange={(value) =>
@@ -293,6 +306,7 @@ export function EmailSettings() {
]}
/>
</SettingItem>
)}
{/* Default Mail Program */}
<SettingItem label={t('default_mail_program.label')} description={t('default_mail_program.description', { appName: appName || 'Bulwark' })}>
@@ -7,6 +7,7 @@ import { playNotificationSound, NOTIFICATION_SOUNDS } from '@/lib/notification-s
import type { NotificationSoundChoice } from '@/lib/notification-sound';
import { Button } from '@/components/ui/button';
import { Volume2 } from 'lucide-react';
import { usePolicyStore } from '@/stores/policy-store';
export function NotificationSettings() {
const t = useTranslations('settings.notifications');
@@ -19,6 +20,7 @@ export function NotificationSettings() {
calendarInvitationParsingEnabled,
updateSetting,
} = useSettingsStore();
const { isSettingLocked, isSettingHidden } = usePolicyStore();
const soundOptions = NOTIFICATION_SOUNDS.map((s) => ({
value: s.id,
@@ -56,15 +58,18 @@ export function NotificationSettings() {
</SettingsSection>
<SettingsSection title={t('email.title')} description={t('email.description')}>
{!isSettingHidden('emailNotificationsEnabled') && (
<SettingItem
label={t('email.enabled')}
description={t('email.enabled_desc')}
locked={isSettingLocked('emailNotificationsEnabled')}
>
<ToggleSwitch
checked={emailNotificationsEnabled}
onChange={(checked) => updateSetting('emailNotificationsEnabled', checked)}
/>
</SettingItem>
)}
<SettingItem
label={t('email.sound')}
@@ -79,15 +84,18 @@ export function NotificationSettings() {
</SettingsSection>
<SettingsSection title={t('calendar.title')} description={t('calendar.description')}>
{!isSettingHidden('calendarNotificationsEnabled') && (
<SettingItem
label={t('calendar.enabled')}
description={t('calendar.enabled_desc')}
locked={isSettingLocked('calendarNotificationsEnabled')}
>
<ToggleSwitch
checked={calendarNotificationsEnabled}
onChange={(checked) => updateSetting('calendarNotificationsEnabled', checked)}
/>
</SettingItem>
)}
<SettingItem
label={t('calendar.sound')}
+33 -4
View File
@@ -1,6 +1,6 @@
'use client';
import { useState, useRef } from 'react';
import { useState, useRef, useEffect } from 'react';
import { useThemeStore } from '@/stores/theme-store';
import { SettingsSection, SettingItem } from './settings-section';
import { cn } from '@/lib/utils';
@@ -8,11 +8,30 @@ 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';
import { usePolicyStore } from '@/stores/policy-store';
export function ThemesSettings() {
const { installedThemes, activeThemeId, installTheme, uninstallTheme, activateTheme } = useThemeStore();
const [isUploading, setIsUploading] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const { isFeatureEnabled, isThemeDisabled, getThemePolicy } = usePolicyStore();
const canUpload = isFeatureEnabled('userThemesEnabled');
const themePolicy = getThemePolicy();
// Filter out themes disabled by admin policy
const visibleThemes = installedThemes.filter(
theme => !isThemeDisabled(theme.id, !!theme.builtIn)
);
// If the active theme was disabled by admin, fall back to default
useEffect(() => {
if (activeThemeId) {
const activeTheme = installedThemes.find(t => t.id === activeThemeId);
if (activeTheme && isThemeDisabled(activeThemeId, !!activeTheme.builtIn)) {
activateTheme(null);
}
}
}, [activeThemeId, installedThemes, isThemeDisabled, activateTheme]);
const handleUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
@@ -59,11 +78,12 @@ export function ThemesSettings() {
author="Bulwark"
isActive={activeThemeId === null}
isBuiltIn
isDefault={!themePolicy.defaultThemeId}
onActivate={() => handleActivate(null)}
/>
{/* Installed themes */}
{installedThemes.map(theme => (
{visibleThemes.map(theme => (
<ThemeCard
key={theme.id}
name={theme.name}
@@ -71,6 +91,7 @@ export function ThemesSettings() {
preview={theme.preview}
isActive={activeThemeId === theme.id}
isBuiltIn={theme.builtIn}
isDefault={themePolicy.defaultThemeId === theme.id}
variants={theme.variants}
onActivate={() => handleActivate(theme.id)}
onRemove={!theme.builtIn ? () => handleUninstall(theme) : undefined}
@@ -79,6 +100,7 @@ export function ThemesSettings() {
</div>
{/* Upload */}
{canUpload && (
<SettingItem label="Upload Theme" description="Install a custom theme from a .zip file containing manifest.json and theme.css">
<input
ref={fileInputRef}
@@ -98,6 +120,7 @@ export function ThemesSettings() {
{isUploading ? 'Installing...' : 'Upload .zip'}
</Button>
</SettingItem>
)}
</SettingsSection>
);
}
@@ -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 (
<button
onClick={onActivate}
@@ -139,7 +163,12 @@ function ThemeCard({ name, author, preview, isActive, variants, onActivate, onRe
<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 className="flex items-center gap-1 flex-shrink-0">
{isDefault && (
<span className="text-[10px] px-1.5 py-0.5 rounded bg-primary/10 text-primary font-medium">Default</span>
)}
{isActive && <Check className="w-4 h-4 text-primary" />}
</div>
</div>
<span className="text-xs text-muted-foreground truncate block">{author}</span>
{variants && (
+10 -2
View File
@@ -2,7 +2,7 @@ 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';
import { CONFIG_ENV_MAP, DEFAULT_POLICY, DEFAULT_THEME_POLICY, type SettingsPolicy } from './types';
function getAdminDir(): string {
return process.env.ADMIN_DATA_DIR || path.join(process.cwd(), 'data', 'admin');
@@ -30,7 +30,15 @@ class ConfigManager {
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 };
if (policy) {
this.policyCache = {
...DEFAULT_POLICY,
...policy,
themePolicy: { ...DEFAULT_THEME_POLICY, ...(policy.themePolicy || {}) },
};
} else {
this.policyCache = { ...DEFAULT_POLICY };
}
this.loaded = true;
logger.debug('ConfigManager loaded', { configKeys: Object.keys(this.adminConfig).length });
}
+217
View File
@@ -0,0 +1,217 @@
import { readFile, writeFile, mkdir, rename, unlink } from 'node:fs/promises';
import { existsSync } from 'node:fs';
import path from 'node:path';
import { logger } from '@/lib/logger';
function getAdminDir(): string {
return process.env.ADMIN_DATA_DIR || path.join(process.cwd(), 'data', 'admin');
}
function getPluginsDir(): string {
return path.join(getAdminDir(), 'plugins');
}
function getThemesDir(): string {
return path.join(getAdminDir(), 'themes');
}
// ─── Types ───────────────────────────────────────────────────
export interface ServerPlugin {
id: string;
name: string;
version: string;
author: string;
description: string;
type: string;
permissions: string[];
entrypoint: string;
enabled: boolean;
installedAt: string;
updatedAt: string;
}
export interface ServerTheme {
id: string;
name: string;
version: string;
author: string;
description: string;
variants: string[];
enabled: boolean;
installedAt: string;
updatedAt: string;
}
interface PluginRegistry {
plugins: ServerPlugin[];
}
interface ThemeRegistry {
themes: ServerTheme[];
}
// ─── Plugin Registry ─────────────────────────────────────────
async function ensureDir(dir: string): Promise<void> {
if (!existsSync(dir)) {
await mkdir(dir, { recursive: true });
}
}
async function readJsonFile<T>(filePath: string, fallback: T): Promise<T> {
try {
const raw = await readFile(filePath, 'utf-8');
return JSON.parse(raw);
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return fallback;
logger.warn(`Failed to read ${filePath}`, { error: error instanceof Error ? error.message : 'Unknown error' });
return fallback;
}
}
async function writeJsonFile(filePath: string, data: unknown): Promise<void> {
const dir = path.dirname(filePath);
await ensureDir(dir);
const tmpPath = filePath + '.tmp';
await writeFile(tmpPath, JSON.stringify(data, null, 2), 'utf-8');
await rename(tmpPath, filePath);
}
// ─── Plugin Operations ───────────────────────────────────────
const pluginRegistryPath = () => path.join(getPluginsDir(), 'registry.json');
export async function getPluginRegistry(): Promise<PluginRegistry> {
return readJsonFile<PluginRegistry>(pluginRegistryPath(), { plugins: [] });
}
export async function getPlugin(id: string): Promise<ServerPlugin | null> {
const registry = await getPluginRegistry();
return registry.plugins.find(p => p.id === id) || null;
}
export async function savePlugin(
plugin: ServerPlugin,
code: string,
): Promise<void> {
const dir = getPluginsDir();
await ensureDir(dir);
// Save code bundle
const bundlePath = path.join(dir, `${plugin.id}.js`);
await writeFile(bundlePath, code, 'utf-8');
// Update registry
const registry = await getPluginRegistry();
const idx = registry.plugins.findIndex(p => p.id === plugin.id);
if (idx >= 0) {
registry.plugins[idx] = plugin;
} else {
registry.plugins.push(plugin);
}
await writeJsonFile(pluginRegistryPath(), registry);
}
export async function updatePluginMeta(id: string, updates: Partial<Pick<ServerPlugin, 'enabled'>>): Promise<ServerPlugin | null> {
const registry = await getPluginRegistry();
const idx = registry.plugins.findIndex(p => p.id === id);
if (idx < 0) return null;
registry.plugins[idx] = { ...registry.plugins[idx], ...updates, updatedAt: new Date().toISOString() };
await writeJsonFile(pluginRegistryPath(), registry);
return registry.plugins[idx];
}
export async function deletePlugin(id: string): Promise<boolean> {
const registry = await getPluginRegistry();
const idx = registry.plugins.findIndex(p => p.id === id);
if (idx < 0) return false;
registry.plugins.splice(idx, 1);
await writeJsonFile(pluginRegistryPath(), registry);
// Remove bundle file
const bundlePath = path.join(getPluginsDir(), `${id}.js`);
try { await unlink(bundlePath); } catch { /* ok if missing */ }
return true;
}
export async function getPluginBundle(id: string): Promise<string | null> {
const bundlePath = path.join(getPluginsDir(), `${id}.js`);
try {
return await readFile(bundlePath, 'utf-8');
} catch {
return null;
}
}
// ─── Theme Operations ────────────────────────────────────────
const themeRegistryPath = () => path.join(getThemesDir(), 'registry.json');
export async function getThemeRegistry(): Promise<ThemeRegistry> {
return readJsonFile<ThemeRegistry>(themeRegistryPath(), { themes: [] });
}
export async function getTheme(id: string): Promise<ServerTheme | null> {
const registry = await getThemeRegistry();
return registry.themes.find(t => t.id === id) || null;
}
export async function saveTheme(
theme: ServerTheme,
css: string,
): Promise<void> {
const dir = getThemesDir();
await ensureDir(dir);
// Save CSS file
const cssPath = path.join(dir, `${theme.id}.css`);
await writeFile(cssPath, css, 'utf-8');
// Update registry
const registry = await getThemeRegistry();
const idx = registry.themes.findIndex(t => t.id === theme.id);
if (idx >= 0) {
registry.themes[idx] = theme;
} else {
registry.themes.push(theme);
}
await writeJsonFile(themeRegistryPath(), registry);
}
export async function updateThemeMeta(id: string, updates: Partial<Pick<ServerTheme, 'enabled'>>): Promise<ServerTheme | null> {
const registry = await getThemeRegistry();
const idx = registry.themes.findIndex(t => t.id === id);
if (idx < 0) return null;
registry.themes[idx] = { ...registry.themes[idx], ...updates, updatedAt: new Date().toISOString() };
await writeJsonFile(themeRegistryPath(), registry);
return registry.themes[idx];
}
export async function deleteTheme(id: string): Promise<boolean> {
const registry = await getThemeRegistry();
const idx = registry.themes.findIndex(t => t.id === id);
if (idx < 0) return false;
registry.themes.splice(idx, 1);
await writeJsonFile(themeRegistryPath(), registry);
// Remove CSS file
const cssPath = path.join(getThemesDir(), `${id}.css`);
try { await unlink(cssPath); } catch { /* ok if missing */ }
return true;
}
export async function getThemeCSS(id: string): Promise<string | null> {
const cssPath = path.join(getThemesDir(), `${id}.css`);
try {
return await readFile(cssPath, 'utf-8');
} catch {
return null;
}
}
+21
View File
@@ -23,6 +23,8 @@ export interface SettingRestriction {
}
export interface FeatureGates {
pluginsEnabled: boolean;
themesEnabled: boolean;
sidebarAppsEnabled: boolean;
userThemesEnabled: boolean;
settingsExportEnabled: boolean;
@@ -37,6 +39,8 @@ export interface FeatureGates {
}
export const DEFAULT_FEATURE_GATES: FeatureGates = {
pluginsEnabled: true,
themesEnabled: true,
sidebarAppsEnabled: true,
userThemesEnabled: true,
settingsExportEnabled: true,
@@ -50,16 +54,33 @@ export const DEFAULT_FEATURE_GATES: FeatureGates = {
hoverActionsConfigEnabled: true,
};
export interface ThemePolicy {
/** Built-in theme IDs that are disabled (hidden from users) */
disabledBuiltinThemes: string[];
/** Admin-deployed theme IDs that are disabled (hidden from users) */
disabledThemes: string[];
/** Default theme ID for new users (null = system default) */
defaultThemeId: string | null;
}
export const DEFAULT_THEME_POLICY: ThemePolicy = {
disabledBuiltinThemes: [],
disabledThemes: [],
defaultThemeId: null,
};
export interface SettingsPolicy {
restrictions: Record<string, SettingRestriction>;
features: FeatureGates;
defaults: Record<string, unknown>;
themePolicy: ThemePolicy;
}
export const DEFAULT_POLICY: SettingsPolicy = {
restrictions: {},
features: { ...DEFAULT_FEATURE_GATES },
defaults: {},
themePolicy: { ...DEFAULT_THEME_POLICY },
};
export interface AuditEntry {
+16 -2
View File
@@ -1,6 +1,6 @@
import { create } from 'zustand';
import type { SettingsPolicy, FeatureGates, SettingRestriction } from '@/lib/admin/types';
import { DEFAULT_POLICY } from '@/lib/admin/types';
import type { SettingsPolicy, FeatureGates, SettingRestriction, ThemePolicy } from '@/lib/admin/types';
import { DEFAULT_POLICY, DEFAULT_THEME_POLICY } from '@/lib/admin/types';
interface PolicyState {
policy: SettingsPolicy;
@@ -11,6 +11,8 @@ interface PolicyState {
isFeatureEnabled: (feature: keyof FeatureGates) => boolean;
getRestriction: (key: string) => SettingRestriction | undefined;
getEffectiveDefault: (key: string) => unknown;
getThemePolicy: () => ThemePolicy;
isThemeDisabled: (themeId: string, isBuiltIn: boolean) => boolean;
}
export const usePolicyStore = create<PolicyState>()((set, get) => ({
@@ -52,4 +54,16 @@ export const usePolicyStore = create<PolicyState>()((set, get) => ({
getEffectiveDefault: (key) => {
return get().policy.defaults[key];
},
getThemePolicy: () => {
return get().policy.themePolicy || { ...DEFAULT_THEME_POLICY };
},
isThemeDisabled: (themeId, isBuiltIn) => {
const tp = get().policy.themePolicy || DEFAULT_THEME_POLICY;
if (isBuiltIn) {
return (tp.disabledBuiltinThemes || []).includes(themeId);
}
return (tp.disabledThemes || []).includes(themeId);
},
}));
+17 -4
View File
@@ -5,6 +5,7 @@ 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';
import { usePolicyStore } from '@/stores/policy-store';
type Theme = 'light' | 'dark' | 'system';
@@ -87,21 +88,33 @@ export const useThemeStore = create<ThemeState>()(
applyTheme(resolvedTheme);
set({ resolvedTheme, hydrated: true });
// Determine effective theme: user choice > policy default > none
let effectiveThemeId = activeThemeId;
if (!effectiveThemeId) {
const policyState = usePolicyStore.getState();
const tp = policyState.policy.themePolicy;
if (tp?.defaultThemeId) {
effectiveThemeId = tp.defaultThemeId;
// Persist so we don't re-check every time
set({ activeThemeId: effectiveThemeId });
}
}
// Apply active custom theme on boot
if (activeThemeId) {
const t = installedThemes.find(t => t.id === activeThemeId);
if (effectiveThemeId) {
const t = installedThemes.find(t => t.id === effectiveThemeId);
if (t) {
// Load CSS from IndexedDB (may have been stripped from localStorage)
if (t.css) {
applyCustomThemeCSS(t, resolvedTheme);
} else {
pluginStorage.getThemeCSS(activeThemeId).then(css => {
pluginStorage.getThemeCSS(effectiveThemeId).then(css => {
if (css) {
injectThemeCSS(css);
// Update the in-memory cache
set({
installedThemes: installedThemes.map(
it => it.id === activeThemeId ? { ...it, css } : it
it => it.id === effectiveThemeId ? { ...it, css } : it
),
});
}