feat: implement force enable/disable functionality for plugins and themes
This commit is contained in:
+122
-1
@@ -1,7 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useEffect, useState, useRef } from 'react';
|
import { useEffect, useState, useRef } from 'react';
|
||||||
import { Upload, Trash2, Power, AlertTriangle, Loader2, Package, Save, Shield } from 'lucide-react';
|
import { Upload, Trash2, Power, PowerOff, AlertTriangle, Loader2, Package, Save, Shield, Lock, LockOpen } from 'lucide-react';
|
||||||
import type { SettingsPolicy } from '@/lib/admin/types';
|
import type { SettingsPolicy } from '@/lib/admin/types';
|
||||||
import { DEFAULT_POLICY } from '@/lib/admin/types';
|
import { DEFAULT_POLICY } from '@/lib/admin/types';
|
||||||
|
|
||||||
@@ -13,6 +13,7 @@ interface PluginEntry {
|
|||||||
description: string;
|
description: string;
|
||||||
type: string;
|
type: string;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
|
forceEnabled?: boolean;
|
||||||
permissions: string[];
|
permissions: string[];
|
||||||
installedAt: string;
|
installedAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
@@ -130,6 +131,88 @@ export default function AdminPluginsPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function toggleForceEnabled(id: string, forceEnabled: boolean) {
|
||||||
|
setMessage(null);
|
||||||
|
// If force-enabling, also ensure the plugin is enabled
|
||||||
|
const body: Record<string, unknown> = { id, forceEnabled };
|
||||||
|
if (forceEnabled) body.enabled = true;
|
||||||
|
|
||||||
|
const res = await fetch('/api/admin/plugins', {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
setPlugins(prev => prev.map(p => p.id === id ? { ...p, forceEnabled, ...(forceEnabled ? { enabled: true } : {}) } : p));
|
||||||
|
// Also update policy
|
||||||
|
setPolicy(prev => {
|
||||||
|
const current = prev.forceEnabledPlugins || [];
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
forceEnabledPlugins: forceEnabled
|
||||||
|
? [...current.filter(pid => pid !== id), id]
|
||||||
|
: current.filter(pid => pid !== id),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
setPolicyDirty(true);
|
||||||
|
} else {
|
||||||
|
const data = await res.json();
|
||||||
|
setMessage({ type: 'error', text: data.error || 'Update failed' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function forceEnableAll() {
|
||||||
|
setMessage(null);
|
||||||
|
const disabled = plugins.filter(p => !p.enabled);
|
||||||
|
if (disabled.length === 0) {
|
||||||
|
setMessage({ type: 'success', text: 'All plugins are already enabled' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let failed = 0;
|
||||||
|
for (const p of disabled) {
|
||||||
|
const res = await fetch('/api/admin/plugins', {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ id: p.id, enabled: true }),
|
||||||
|
});
|
||||||
|
if (!res.ok) failed++;
|
||||||
|
}
|
||||||
|
setPlugins(prev => prev.map(p => failed === 0 ? { ...p, enabled: true } : p));
|
||||||
|
if (failed === 0) {
|
||||||
|
await fetchPlugins();
|
||||||
|
setMessage({ type: 'success', text: `All ${disabled.length} plugin(s) enabled` });
|
||||||
|
} else {
|
||||||
|
await fetchPlugins();
|
||||||
|
setMessage({ type: 'error', text: `${failed} plugin(s) failed to enable` });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function forceDisableAll() {
|
||||||
|
setMessage(null);
|
||||||
|
const enabled = plugins.filter(p => p.enabled);
|
||||||
|
if (enabled.length === 0) {
|
||||||
|
setMessage({ type: 'success', text: 'All plugins are already disabled' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let failed = 0;
|
||||||
|
for (const p of enabled) {
|
||||||
|
const res = await fetch('/api/admin/plugins', {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ id: p.id, enabled: false }),
|
||||||
|
});
|
||||||
|
if (!res.ok) failed++;
|
||||||
|
}
|
||||||
|
if (failed === 0) {
|
||||||
|
await fetchPlugins();
|
||||||
|
setMessage({ type: 'success', text: `All ${enabled.length} plugin(s) disabled` });
|
||||||
|
} else {
|
||||||
|
await fetchPlugins();
|
||||||
|
setMessage({ type: 'error', text: `${failed} plugin(s) failed to disable` });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function deletePlugin(id: string, name: string) {
|
async function deletePlugin(id: string, name: string) {
|
||||||
if (!confirm(`Remove plugin "${name}"? This cannot be undone.`)) return;
|
if (!confirm(`Remove plugin "${name}"? This cannot be undone.`)) return;
|
||||||
|
|
||||||
@@ -214,6 +297,32 @@ export default function AdminPluginsPage() {
|
|||||||
<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]'}`} />
|
<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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Force enable / disable all */}
|
||||||
|
{plugins.length > 0 && (
|
||||||
|
<div className="px-4 py-3 flex items-center justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<span className="text-sm text-foreground">Force Enable / Disable All</span>
|
||||||
|
<p className="text-xs text-muted-foreground mt-0.5">Bulk toggle all deployed plugins at once</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={forceEnableAll}
|
||||||
|
className="inline-flex items-center gap-1.5 h-7 px-3 rounded-md bg-emerald-600 text-white text-xs font-medium hover:bg-emerald-700 transition-colors"
|
||||||
|
>
|
||||||
|
<Power className="w-3.5 h-3.5" />
|
||||||
|
Enable All
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={forceDisableAll}
|
||||||
|
className="inline-flex items-center gap-1.5 h-7 px-3 rounded-md bg-muted text-muted-foreground text-xs font-medium hover:bg-accent hover:text-foreground transition-colors"
|
||||||
|
>
|
||||||
|
<PowerOff className="w-3.5 h-3.5" />
|
||||||
|
Disable All
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -243,6 +352,11 @@ export default function AdminPluginsPage() {
|
|||||||
<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'}`}>
|
<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'}
|
{plugin.enabled ? 'Enabled' : 'Disabled'}
|
||||||
</span>
|
</span>
|
||||||
|
{plugin.forceEnabled && (
|
||||||
|
<span className="text-xs px-1.5 py-0.5 rounded bg-amber-100 text-amber-700 dark:bg-amber-950/30 dark:text-amber-400 flex items-center gap-1">
|
||||||
|
<Lock className="w-3 h-3" /> Forced
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{plugin.description && (
|
{plugin.description && (
|
||||||
<p className="text-xs text-muted-foreground mt-0.5 truncate">{plugin.description}</p>
|
<p className="text-xs text-muted-foreground mt-0.5 truncate">{plugin.description}</p>
|
||||||
@@ -261,6 +375,13 @@ export default function AdminPluginsPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => toggleForceEnabled(plugin.id, !plugin.forceEnabled)}
|
||||||
|
title={plugin.forceEnabled ? 'Remove force-enable (users can disable)' : 'Force enable (users cannot disable)'}
|
||||||
|
className={`p-2 rounded-md transition-colors ${plugin.forceEnabled ? 'bg-amber-100 text-amber-700 hover:bg-amber-200 dark:bg-amber-950/30 dark:text-amber-400 dark:hover:bg-amber-950/50' : 'hover:bg-accent text-muted-foreground hover:text-foreground'}`}
|
||||||
|
>
|
||||||
|
{plugin.forceEnabled ? <Lock className="w-4 h-4" /> : <LockOpen className="w-4 h-4" />}
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => togglePlugin(plugin.id, !plugin.enabled)}
|
onClick={() => togglePlugin(plugin.id, !plugin.enabled)}
|
||||||
title={plugin.enabled ? 'Disable' : 'Enable'}
|
title={plugin.enabled ? 'Disable' : 'Enable'}
|
||||||
|
|||||||
+119
-1
@@ -1,7 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useEffect, useState, useRef } from 'react';
|
import { useEffect, useState, useRef } from 'react';
|
||||||
import { Upload, Trash2, Power, Loader2, Palette, Save, Shield } from 'lucide-react';
|
import { Upload, Trash2, Power, PowerOff, Loader2, Palette, Save, Shield, Lock, LockOpen } from 'lucide-react';
|
||||||
import type { SettingsPolicy } from '@/lib/admin/types';
|
import type { SettingsPolicy } from '@/lib/admin/types';
|
||||||
import { DEFAULT_POLICY, DEFAULT_THEME_POLICY } from '@/lib/admin/types';
|
import { DEFAULT_POLICY, DEFAULT_THEME_POLICY } from '@/lib/admin/types';
|
||||||
|
|
||||||
@@ -19,6 +19,7 @@ interface ThemeEntry {
|
|||||||
description: string;
|
description: string;
|
||||||
variants: string[];
|
variants: string[];
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
|
forceEnabled?: boolean;
|
||||||
installedAt: string;
|
installedAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
@@ -198,6 +199,85 @@ export default function AdminThemesPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function toggleForceEnabled(id: string, forceEnabled: boolean) {
|
||||||
|
setMessage(null);
|
||||||
|
const body: Record<string, unknown> = { id, forceEnabled };
|
||||||
|
if (forceEnabled) body.enabled = true;
|
||||||
|
|
||||||
|
const res = await fetch('/api/admin/themes', {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
setThemes(prev => prev.map(t => t.id === id ? { ...t, forceEnabled, ...(forceEnabled ? { enabled: true } : {}) } : t));
|
||||||
|
setPolicy(prev => {
|
||||||
|
const current = prev.forceEnabledThemes || [];
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
forceEnabledThemes: forceEnabled
|
||||||
|
? [...current.filter(tid => tid !== id), id]
|
||||||
|
: current.filter(tid => tid !== id),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
setPolicyDirty(true);
|
||||||
|
} else {
|
||||||
|
const data = await res.json();
|
||||||
|
setMessage({ type: 'error', text: data.error || 'Update failed' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function forceEnableAll() {
|
||||||
|
setMessage(null);
|
||||||
|
const disabled = themes.filter(t => !t.enabled);
|
||||||
|
if (disabled.length === 0) {
|
||||||
|
setMessage({ type: 'success', text: 'All themes are already enabled' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let failed = 0;
|
||||||
|
for (const t of disabled) {
|
||||||
|
const res = await fetch('/api/admin/themes', {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ id: t.id, enabled: true }),
|
||||||
|
});
|
||||||
|
if (!res.ok) failed++;
|
||||||
|
}
|
||||||
|
if (failed === 0) {
|
||||||
|
await fetchThemes();
|
||||||
|
setMessage({ type: 'success', text: `All ${disabled.length} theme(s) enabled` });
|
||||||
|
} else {
|
||||||
|
await fetchThemes();
|
||||||
|
setMessage({ type: 'error', text: `${failed} theme(s) failed to enable` });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function forceDisableAll() {
|
||||||
|
setMessage(null);
|
||||||
|
const enabled = themes.filter(t => t.enabled);
|
||||||
|
if (enabled.length === 0) {
|
||||||
|
setMessage({ type: 'success', text: 'All themes are already disabled' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let failed = 0;
|
||||||
|
for (const t of enabled) {
|
||||||
|
const res = await fetch('/api/admin/themes', {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ id: t.id, enabled: false }),
|
||||||
|
});
|
||||||
|
if (!res.ok) failed++;
|
||||||
|
}
|
||||||
|
if (failed === 0) {
|
||||||
|
await fetchThemes();
|
||||||
|
setMessage({ type: 'success', text: `All ${enabled.length} theme(s) disabled` });
|
||||||
|
} else {
|
||||||
|
await fetchThemes();
|
||||||
|
setMessage({ type: 'error', text: `${failed} theme(s) failed to disable` });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function deleteTheme(id: string, name: string) {
|
async function deleteTheme(id: string, name: string) {
|
||||||
if (!confirm(`Remove theme "${name}"? This cannot be undone.`)) return;
|
if (!confirm(`Remove theme "${name}"? This cannot be undone.`)) return;
|
||||||
|
|
||||||
@@ -298,6 +378,32 @@ export default function AdminThemesPage() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Force enable / disable all */}
|
||||||
|
{themes.length > 0 && (
|
||||||
|
<div className="px-4 py-3 flex items-center justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<span className="text-sm text-foreground">Force Enable / Disable All</span>
|
||||||
|
<p className="text-xs text-muted-foreground mt-0.5">Bulk toggle all deployed themes at once</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={forceEnableAll}
|
||||||
|
className="inline-flex items-center gap-1.5 h-7 px-3 rounded-md bg-emerald-600 text-white text-xs font-medium hover:bg-emerald-700 transition-colors"
|
||||||
|
>
|
||||||
|
<Power className="w-3.5 h-3.5" />
|
||||||
|
Enable All
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={forceDisableAll}
|
||||||
|
className="inline-flex items-center gap-1.5 h-7 px-3 rounded-md bg-muted text-muted-foreground text-xs font-medium hover:bg-accent hover:text-foreground transition-colors"
|
||||||
|
>
|
||||||
|
<PowerOff className="w-3.5 h-3.5" />
|
||||||
|
Disable All
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Default Theme */}
|
{/* Default Theme */}
|
||||||
<div className="px-4 py-3">
|
<div className="px-4 py-3">
|
||||||
<div className="flex items-center justify-between gap-4">
|
<div className="flex items-center justify-between gap-4">
|
||||||
@@ -399,6 +505,11 @@ export default function AdminThemesPage() {
|
|||||||
<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'}`}>
|
<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'}
|
{theme.enabled ? 'Enabled' : 'Disabled'}
|
||||||
</span>
|
</span>
|
||||||
|
{theme.forceEnabled && (
|
||||||
|
<span className="text-xs px-1.5 py-0.5 rounded bg-amber-100 text-amber-700 dark:bg-amber-950/30 dark:text-amber-400 flex items-center gap-1">
|
||||||
|
<Lock className="w-3 h-3" /> Forced
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{theme.description && (
|
{theme.description && (
|
||||||
<p className="text-xs text-muted-foreground mt-0.5 truncate">{theme.description}</p>
|
<p className="text-xs text-muted-foreground mt-0.5 truncate">{theme.description}</p>
|
||||||
@@ -409,6 +520,13 @@ export default function AdminThemesPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => toggleForceEnabled(theme.id, !theme.forceEnabled)}
|
||||||
|
title={theme.forceEnabled ? 'Remove force-enable (users can deactivate)' : 'Force enable (users cannot deactivate)'}
|
||||||
|
className={`p-2 rounded-md transition-colors ${theme.forceEnabled ? 'bg-amber-100 text-amber-700 hover:bg-amber-200 dark:bg-amber-950/30 dark:text-amber-400 dark:hover:bg-amber-950/50' : 'hover:bg-accent text-muted-foreground hover:text-foreground'}`}
|
||||||
|
>
|
||||||
|
{theme.forceEnabled ? <Lock className="w-4 h-4" /> : <LockOpen className="w-4 h-4" />}
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => toggleTheme(theme.id, !theme.enabled)}
|
onClick={() => toggleTheme(theme.id, !theme.enabled)}
|
||||||
title={theme.enabled ? 'Disable' : 'Enable'}
|
title={theme.enabled ? 'Disable' : 'Enable'}
|
||||||
|
|||||||
@@ -181,22 +181,26 @@ export async function PATCH(request: NextRequest) {
|
|||||||
if ('error' in result) return result.error;
|
if ('error' in result) return result.error;
|
||||||
|
|
||||||
const ip = getClientIP(request);
|
const ip = getClientIP(request);
|
||||||
const { id, enabled } = await request.json();
|
const { id, enabled, forceEnabled } = await request.json();
|
||||||
|
|
||||||
if (!id || typeof id !== 'string') {
|
if (!id || typeof id !== 'string') {
|
||||||
return NextResponse.json({ error: 'Missing plugin id' }, { status: 400 });
|
return NextResponse.json({ error: 'Missing plugin id' }, { status: 400 });
|
||||||
}
|
}
|
||||||
if (typeof enabled !== 'boolean') {
|
if (typeof enabled !== 'boolean' && typeof forceEnabled !== 'boolean') {
|
||||||
return NextResponse.json({ error: 'enabled must be a boolean' }, { status: 400 });
|
return NextResponse.json({ error: 'enabled or forceEnabled must be a boolean' }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const updates: { enabled?: boolean; forceEnabled?: boolean } = {};
|
||||||
|
if (typeof enabled === 'boolean') updates.enabled = enabled;
|
||||||
|
if (typeof forceEnabled === 'boolean') updates.forceEnabled = forceEnabled;
|
||||||
|
|
||||||
const { updatePluginMeta } = await import('@/lib/admin/plugin-registry');
|
const { updatePluginMeta } = await import('@/lib/admin/plugin-registry');
|
||||||
const updated = await updatePluginMeta(id, { enabled });
|
const updated = await updatePluginMeta(id, updates);
|
||||||
if (!updated) {
|
if (!updated) {
|
||||||
return NextResponse.json({ error: 'Plugin not found' }, { status: 404 });
|
return NextResponse.json({ error: 'Plugin not found' }, { status: 404 });
|
||||||
}
|
}
|
||||||
|
|
||||||
await auditLog('plugin.update', { id, enabled }, ip);
|
await auditLog('plugin.update', { id, ...updates }, ip);
|
||||||
return NextResponse.json({ plugin: updated });
|
return NextResponse.json({ plugin: updated });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Plugin update error', { error: error instanceof Error ? error.message : 'Unknown error' });
|
logger.error('Plugin update error', { error: error instanceof Error ? error.message : 'Unknown error' });
|
||||||
|
|||||||
@@ -160,22 +160,26 @@ export async function PATCH(request: NextRequest) {
|
|||||||
if ('error' in result) return result.error;
|
if ('error' in result) return result.error;
|
||||||
|
|
||||||
const ip = getClientIP(request);
|
const ip = getClientIP(request);
|
||||||
const { id, enabled } = await request.json();
|
const { id, enabled, forceEnabled } = await request.json();
|
||||||
|
|
||||||
if (!id || typeof id !== 'string') {
|
if (!id || typeof id !== 'string') {
|
||||||
return NextResponse.json({ error: 'Missing theme id' }, { status: 400 });
|
return NextResponse.json({ error: 'Missing theme id' }, { status: 400 });
|
||||||
}
|
}
|
||||||
if (typeof enabled !== 'boolean') {
|
if (typeof enabled !== 'boolean' && typeof forceEnabled !== 'boolean') {
|
||||||
return NextResponse.json({ error: 'enabled must be a boolean' }, { status: 400 });
|
return NextResponse.json({ error: 'enabled or forceEnabled must be a boolean' }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const updates: { enabled?: boolean; forceEnabled?: boolean } = {};
|
||||||
|
if (typeof enabled === 'boolean') updates.enabled = enabled;
|
||||||
|
if (typeof forceEnabled === 'boolean') updates.forceEnabled = forceEnabled;
|
||||||
|
|
||||||
const { updateThemeMeta } = await import('@/lib/admin/plugin-registry');
|
const { updateThemeMeta } = await import('@/lib/admin/plugin-registry');
|
||||||
const updated = await updateThemeMeta(id, { enabled });
|
const updated = await updateThemeMeta(id, updates);
|
||||||
if (!updated) {
|
if (!updated) {
|
||||||
return NextResponse.json({ error: 'Theme not found' }, { status: 404 });
|
return NextResponse.json({ error: 'Theme not found' }, { status: 404 });
|
||||||
}
|
}
|
||||||
|
|
||||||
await auditLog('theme.update', { id, enabled }, ip);
|
await auditLog('theme.update', { id, ...updates }, ip);
|
||||||
return NextResponse.json({ theme: updated });
|
return NextResponse.json({ theme: updated });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Theme update error', { error: error instanceof Error ? error.message : 'Unknown error' });
|
logger.error('Theme update error', { error: error instanceof Error ? error.message : 'Unknown error' });
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { usePluginStore } from '@/stores/plugin-store';
|
|||||||
import { usePolicyStore } from '@/stores/policy-store';
|
import { usePolicyStore } from '@/stores/policy-store';
|
||||||
import { SettingsSection, SettingItem, ToggleSwitch } from './settings-section';
|
import { SettingsSection, SettingItem, ToggleSwitch } from './settings-section';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { Upload, Trash2, AlertTriangle, Puzzle } from 'lucide-react';
|
import { Upload, Trash2, AlertTriangle, Puzzle, Lock } from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { toast } from '@/stores/toast-store';
|
import { toast } from '@/stores/toast-store';
|
||||||
import type { InstalledPlugin, PluginStatus, SettingFieldSchema } from '@/lib/plugin-types';
|
import type { InstalledPlugin, PluginStatus, SettingFieldSchema } from '@/lib/plugin-types';
|
||||||
@@ -20,7 +20,7 @@ const STATUS_COLORS: Record<PluginStatus, string> = {
|
|||||||
|
|
||||||
export function PluginsSettings() {
|
export function PluginsSettings() {
|
||||||
const { plugins, installPlugin, uninstallPlugin, enablePlugin, disablePlugin, updatePluginSettings } = usePluginStore();
|
const { plugins, installPlugin, uninstallPlugin, enablePlugin, disablePlugin, updatePluginSettings } = usePluginStore();
|
||||||
const { isFeatureEnabled } = usePolicyStore();
|
const { isFeatureEnabled, isPluginForceEnabled } = usePolicyStore();
|
||||||
const [isUploading, setIsUploading] = useState(false);
|
const [isUploading, setIsUploading] = useState(false);
|
||||||
const [expandedPlugin, setExpandedPlugin] = useState<string | null>(null);
|
const [expandedPlugin, setExpandedPlugin] = useState<string | null>(null);
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
@@ -53,6 +53,7 @@ export function PluginsSettings() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleToggle = async (plugin: InstalledPlugin) => {
|
const handleToggle = async (plugin: InstalledPlugin) => {
|
||||||
|
if (isPluginForceEnabled(plugin.id)) return;
|
||||||
if (plugin.enabled) {
|
if (plugin.enabled) {
|
||||||
disablePlugin(plugin.id);
|
disablePlugin(plugin.id);
|
||||||
toast.info(`Plugin "${plugin.name}" disabled`);
|
toast.info(`Plugin "${plugin.name}" disabled`);
|
||||||
@@ -83,6 +84,7 @@ export function PluginsSettings() {
|
|||||||
key={plugin.id}
|
key={plugin.id}
|
||||||
plugin={plugin}
|
plugin={plugin}
|
||||||
isExpanded={expandedPlugin === plugin.id}
|
isExpanded={expandedPlugin === plugin.id}
|
||||||
|
isForceEnabled={isPluginForceEnabled(plugin.id)}
|
||||||
onToggleExpand={() => setExpandedPlugin(expandedPlugin === plugin.id ? null : plugin.id)}
|
onToggleExpand={() => setExpandedPlugin(expandedPlugin === plugin.id ? null : plugin.id)}
|
||||||
onToggle={() => handleToggle(plugin)}
|
onToggle={() => handleToggle(plugin)}
|
||||||
onUninstall={() => handleUninstall(plugin)}
|
onUninstall={() => handleUninstall(plugin)}
|
||||||
@@ -121,13 +123,14 @@ export function PluginsSettings() {
|
|||||||
interface PluginCardProps {
|
interface PluginCardProps {
|
||||||
plugin: InstalledPlugin;
|
plugin: InstalledPlugin;
|
||||||
isExpanded: boolean;
|
isExpanded: boolean;
|
||||||
|
isForceEnabled: boolean;
|
||||||
onToggleExpand: () => void;
|
onToggleExpand: () => void;
|
||||||
onToggle: () => void;
|
onToggle: () => void;
|
||||||
onUninstall: () => void;
|
onUninstall: () => void;
|
||||||
onUpdateSettings: (settings: Record<string, unknown>) => void;
|
onUpdateSettings: (settings: Record<string, unknown>) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function PluginCard({ plugin, isExpanded, onToggleExpand, onToggle, onUninstall, onUpdateSettings }: PluginCardProps) {
|
function PluginCard({ plugin, isExpanded, isForceEnabled, onToggleExpand, onToggle, onUninstall, onUpdateSettings }: PluginCardProps) {
|
||||||
return (
|
return (
|
||||||
<div className={cn(
|
<div className={cn(
|
||||||
'rounded-lg border transition-colors',
|
'rounded-lg border transition-colors',
|
||||||
@@ -141,6 +144,11 @@ function PluginCard({ plugin, isExpanded, onToggleExpand, onToggle, onUninstall,
|
|||||||
<span className={cn('text-[10px] px-1.5 py-0.5 rounded-full font-medium', STATUS_COLORS[plugin.status])}>
|
<span className={cn('text-[10px] px-1.5 py-0.5 rounded-full font-medium', STATUS_COLORS[plugin.status])}>
|
||||||
{plugin.status}
|
{plugin.status}
|
||||||
</span>
|
</span>
|
||||||
|
{isForceEnabled && (
|
||||||
|
<span className="text-[10px] px-1.5 py-0.5 rounded-full font-medium bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400 flex items-center gap-0.5">
|
||||||
|
<Lock className="w-2.5 h-2.5" /> Admin enforced
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 mt-0.5">
|
<div className="flex items-center gap-2 mt-0.5">
|
||||||
<span className="text-xs text-muted-foreground">{plugin.author}</span>
|
<span className="text-xs text-muted-foreground">{plugin.author}</span>
|
||||||
@@ -150,7 +158,7 @@ function PluginCard({ plugin, isExpanded, onToggleExpand, onToggle, onUninstall,
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2 flex-shrink-0">
|
<div className="flex items-center gap-2 flex-shrink-0">
|
||||||
<ToggleSwitch checked={plugin.enabled} onChange={onToggle} />
|
<ToggleSwitch checked={plugin.enabled} onChange={onToggle} disabled={isForceEnabled} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { useState, useRef, useEffect } from 'react';
|
|||||||
import { useThemeStore } from '@/stores/theme-store';
|
import { useThemeStore } from '@/stores/theme-store';
|
||||||
import { SettingsSection, SettingItem } from './settings-section';
|
import { SettingsSection, SettingItem } from './settings-section';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { Upload, Trash2, Check, Palette } from 'lucide-react';
|
import { Upload, Trash2, Check, Palette, Lock } from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { toast } from '@/stores/toast-store';
|
import { toast } from '@/stores/toast-store';
|
||||||
import type { InstalledTheme } from '@/lib/plugin-types';
|
import type { InstalledTheme } from '@/lib/plugin-types';
|
||||||
@@ -14,7 +14,7 @@ export function ThemesSettings() {
|
|||||||
const { installedThemes, activeThemeId, installTheme, uninstallTheme, activateTheme } = useThemeStore();
|
const { installedThemes, activeThemeId, installTheme, uninstallTheme, activateTheme } = useThemeStore();
|
||||||
const [isUploading, setIsUploading] = useState(false);
|
const [isUploading, setIsUploading] = useState(false);
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
const { isFeatureEnabled, isThemeDisabled, getThemePolicy } = usePolicyStore();
|
const { isFeatureEnabled, isThemeDisabled, getThemePolicy, isThemeForceEnabled } = usePolicyStore();
|
||||||
const canUpload = isFeatureEnabled('userThemesEnabled');
|
const canUpload = isFeatureEnabled('userThemesEnabled');
|
||||||
const themePolicy = getThemePolicy();
|
const themePolicy = getThemePolicy();
|
||||||
|
|
||||||
@@ -93,6 +93,7 @@ export function ThemesSettings() {
|
|||||||
isActive={activeThemeId === theme.id}
|
isActive={activeThemeId === theme.id}
|
||||||
isBuiltIn={theme.builtIn}
|
isBuiltIn={theme.builtIn}
|
||||||
isDefault={themePolicy.defaultThemeId === theme.id}
|
isDefault={themePolicy.defaultThemeId === theme.id}
|
||||||
|
isForceEnabled={isThemeForceEnabled(theme.id)}
|
||||||
variants={theme.variants}
|
variants={theme.variants}
|
||||||
onActivate={() => handleActivate(theme.id)}
|
onActivate={() => handleActivate(theme.id)}
|
||||||
onRemove={!theme.builtIn ? () => handleUninstall(theme) : undefined}
|
onRemove={!theme.builtIn ? () => handleUninstall(theme) : undefined}
|
||||||
@@ -135,12 +136,13 @@ interface ThemeCardProps {
|
|||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
isBuiltIn: boolean;
|
isBuiltIn: boolean;
|
||||||
isDefault?: boolean;
|
isDefault?: boolean;
|
||||||
|
isForceEnabled?: boolean;
|
||||||
variants?: ('light' | 'dark')[];
|
variants?: ('light' | 'dark')[];
|
||||||
onActivate: () => void;
|
onActivate: () => void;
|
||||||
onRemove?: () => void;
|
onRemove?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function ThemeCard({ name, author, preview, isActive, isDefault, variants, onActivate, onRemove }: ThemeCardProps) {
|
function ThemeCard({ name, author, preview, isActive, isDefault, isForceEnabled, variants, onActivate, onRemove }: ThemeCardProps) {
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
onClick={onActivate}
|
onClick={onActivate}
|
||||||
@@ -165,6 +167,11 @@ function ThemeCard({ name, author, preview, isActive, isDefault, variants, onAct
|
|||||||
<div className="flex items-center justify-between gap-1">
|
<div className="flex items-center justify-between gap-1">
|
||||||
<span className="text-sm font-medium text-foreground truncate">{name}</span>
|
<span className="text-sm font-medium text-foreground truncate">{name}</span>
|
||||||
<div className="flex items-center gap-1 flex-shrink-0">
|
<div className="flex items-center gap-1 flex-shrink-0">
|
||||||
|
{isForceEnabled && (
|
||||||
|
<span className="text-[10px] px-1.5 py-0.5 rounded bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400 font-medium flex items-center gap-0.5" title="Admin enforced">
|
||||||
|
<Lock className="w-2.5 h-2.5" />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
{isDefault && (
|
{isDefault && (
|
||||||
<span className="text-[10px] px-1.5 py-0.5 rounded bg-primary/10 text-primary font-medium">Default</span>
|
<span className="text-[10px] px-1.5 py-0.5 rounded bg-primary/10 text-primary font-medium">Default</span>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ export interface ServerPlugin {
|
|||||||
permissions: string[];
|
permissions: string[];
|
||||||
entrypoint: string;
|
entrypoint: string;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
|
forceEnabled?: boolean;
|
||||||
installedAt: string;
|
installedAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
@@ -39,6 +40,7 @@ export interface ServerTheme {
|
|||||||
description: string;
|
description: string;
|
||||||
variants: string[];
|
variants: string[];
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
|
forceEnabled?: boolean;
|
||||||
installedAt: string;
|
installedAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
@@ -113,7 +115,7 @@ export async function savePlugin(
|
|||||||
await writeJsonFile(pluginRegistryPath(), registry);
|
await writeJsonFile(pluginRegistryPath(), registry);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updatePluginMeta(id: string, updates: Partial<Pick<ServerPlugin, 'enabled'>>): Promise<ServerPlugin | null> {
|
export async function updatePluginMeta(id: string, updates: Partial<Pick<ServerPlugin, 'enabled' | 'forceEnabled'>>): Promise<ServerPlugin | null> {
|
||||||
const registry = await getPluginRegistry();
|
const registry = await getPluginRegistry();
|
||||||
const idx = registry.plugins.findIndex(p => p.id === id);
|
const idx = registry.plugins.findIndex(p => p.id === id);
|
||||||
if (idx < 0) return null;
|
if (idx < 0) return null;
|
||||||
@@ -182,7 +184,7 @@ export async function saveTheme(
|
|||||||
await writeJsonFile(themeRegistryPath(), registry);
|
await writeJsonFile(themeRegistryPath(), registry);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateThemeMeta(id: string, updates: Partial<Pick<ServerTheme, 'enabled'>>): Promise<ServerTheme | null> {
|
export async function updateThemeMeta(id: string, updates: Partial<Pick<ServerTheme, 'enabled' | 'forceEnabled'>>): Promise<ServerTheme | null> {
|
||||||
const registry = await getThemeRegistry();
|
const registry = await getThemeRegistry();
|
||||||
const idx = registry.themes.findIndex(t => t.id === id);
|
const idx = registry.themes.findIndex(t => t.id === id);
|
||||||
if (idx < 0) return null;
|
if (idx < 0) return null;
|
||||||
|
|||||||
@@ -74,6 +74,10 @@ export interface SettingsPolicy {
|
|||||||
features: FeatureGates;
|
features: FeatureGates;
|
||||||
defaults: Record<string, unknown>;
|
defaults: Record<string, unknown>;
|
||||||
themePolicy: ThemePolicy;
|
themePolicy: ThemePolicy;
|
||||||
|
/** Plugin IDs that are force-enabled (users cannot disable) */
|
||||||
|
forceEnabledPlugins: string[];
|
||||||
|
/** Theme IDs that are force-enabled (users cannot deactivate) */
|
||||||
|
forceEnabledThemes: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DEFAULT_POLICY: SettingsPolicy = {
|
export const DEFAULT_POLICY: SettingsPolicy = {
|
||||||
@@ -81,6 +85,8 @@ export const DEFAULT_POLICY: SettingsPolicy = {
|
|||||||
features: { ...DEFAULT_FEATURE_GATES },
|
features: { ...DEFAULT_FEATURE_GATES },
|
||||||
defaults: {},
|
defaults: {},
|
||||||
themePolicy: { ...DEFAULT_THEME_POLICY },
|
themePolicy: { ...DEFAULT_THEME_POLICY },
|
||||||
|
forceEnabledPlugins: [],
|
||||||
|
forceEnabledThemes: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface AuditEntry {
|
export interface AuditEntry {
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ interface PolicyState {
|
|||||||
getEffectiveDefault: (key: string) => unknown;
|
getEffectiveDefault: (key: string) => unknown;
|
||||||
getThemePolicy: () => ThemePolicy;
|
getThemePolicy: () => ThemePolicy;
|
||||||
isThemeDisabled: (themeId: string, isBuiltIn: boolean) => boolean;
|
isThemeDisabled: (themeId: string, isBuiltIn: boolean) => boolean;
|
||||||
|
isPluginForceEnabled: (pluginId: string) => boolean;
|
||||||
|
isThemeForceEnabled: (themeId: string) => boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const usePolicyStore = create<PolicyState>()((set, get) => ({
|
export const usePolicyStore = create<PolicyState>()((set, get) => ({
|
||||||
@@ -66,4 +68,12 @@ export const usePolicyStore = create<PolicyState>()((set, get) => ({
|
|||||||
}
|
}
|
||||||
return (tp.disabledThemes || []).includes(themeId);
|
return (tp.disabledThemes || []).includes(themeId);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
isPluginForceEnabled: (pluginId) => {
|
||||||
|
return (get().policy.forceEnabledPlugins || []).includes(pluginId);
|
||||||
|
},
|
||||||
|
|
||||||
|
isThemeForceEnabled: (themeId) => {
|
||||||
|
return (get().policy.forceEnabledThemes || []).includes(themeId);
|
||||||
|
},
|
||||||
}));
|
}));
|
||||||
|
|||||||
Reference in New Issue
Block a user