feat: restrict plugin and theme install/uninstall to admin dashboard
This commit is contained in:
@@ -1,12 +1,11 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, useRef, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { usePluginStore } from '@/stores/plugin-store';
|
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, ToggleSwitch } from './settings-section';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { Upload, Trash2, AlertTriangle, Puzzle, Lock, Server } from 'lucide-react';
|
import { AlertTriangle, Puzzle, Lock, Server } from 'lucide-react';
|
||||||
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';
|
||||||
|
|
||||||
@@ -19,11 +18,9 @@ const STATUS_COLORS: Record<PluginStatus, string> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export function PluginsSettings() {
|
export function PluginsSettings() {
|
||||||
const { plugins, installPlugin, uninstallPlugin, enablePlugin, disablePlugin, updatePluginSettings, initializePlugins, initialized } = usePluginStore();
|
const { plugins, enablePlugin, disablePlugin, updatePluginSettings, initializePlugins, initialized } = usePluginStore();
|
||||||
const { isFeatureEnabled, isPluginForceEnabled, isPluginApproved, fetchPolicy, loaded } = usePolicyStore();
|
const { isFeatureEnabled, isPluginForceEnabled, isPluginApproved, fetchPolicy, loaded } = usePolicyStore();
|
||||||
const [isUploading, setIsUploading] = useState(false);
|
|
||||||
const [expandedPlugin, setExpandedPlugin] = useState<string | null>(null);
|
const [expandedPlugin, setExpandedPlugin] = useState<string | null>(null);
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!loaded) {
|
if (!loaded) {
|
||||||
@@ -48,36 +45,6 @@ export function PluginsSettings() {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const pluginUploadsEnabled = isFeatureEnabled('pluginsUploadEnabled');
|
|
||||||
|
|
||||||
const handleUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
||||||
if (!pluginUploadsEnabled) {
|
|
||||||
toast.info('Plugin uploads are disabled by your administrator');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const file = e.target.files?.[0];
|
|
||||||
if (!file) return;
|
|
||||||
|
|
||||||
setIsUploading(true);
|
|
||||||
try {
|
|
||||||
const result = await installPlugin(file);
|
|
||||||
if (result.success) {
|
|
||||||
toast.success('Plugin installed');
|
|
||||||
if (result.warnings?.length) {
|
|
||||||
toast.warning('Plugin warnings', { message: result.warnings.join('\n') });
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
toast.error('Plugin installation failed', { message: result.error });
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
toast.error('Plugin installation failed', { message: err instanceof Error ? err.message : 'Unknown error' });
|
|
||||||
} finally {
|
|
||||||
setIsUploading(false);
|
|
||||||
if (fileInputRef.current) fileInputRef.current.value = '';
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleToggle = async (plugin: InstalledPlugin) => {
|
const handleToggle = async (plugin: InstalledPlugin) => {
|
||||||
if (!initialized) return;
|
if (!initialized) return;
|
||||||
|
|
||||||
@@ -103,27 +70,14 @@ export function PluginsSettings() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleUninstall = (plugin: InstalledPlugin) => {
|
|
||||||
if (!initialized) return;
|
|
||||||
|
|
||||||
const isForceEnabled = plugin.forceEnabled || isPluginForceEnabled(plugin.id);
|
|
||||||
if (isForceEnabled) {
|
|
||||||
toast.info(`Plugin "${plugin.name}" is forced by admin and cannot be uninstalled`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
uninstallPlugin(plugin.id);
|
|
||||||
toast.success(`Plugin "${plugin.name}" removed`);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SettingsSection title="Plugins" description="Manage installed plugins. Upload plugin .zip files to add new functionality." experimental experimentalDescription="Plugins is an experimental feature. The plugin API is not yet stable and may change between releases, which could break existing plugins. Plugins run in a sandboxed environment but have access to your data within the application. Only install plugins from sources you trust.">
|
<SettingsSection title="Plugins" description="Plugins deployed by your administrator. Toggle to enable or disable for your account." experimental experimentalDescription="Plugins is an experimental feature. The plugin API is not yet stable and may change between releases, which could break existing plugins. Plugins run in a sandboxed environment but have access to your data within the application.">
|
||||||
{/* Plugin List */}
|
{/* Plugin List */}
|
||||||
{plugins.length === 0 ? (
|
{plugins.length === 0 ? (
|
||||||
<div className="flex flex-col items-center justify-center py-12 text-center">
|
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||||
<Puzzle className="w-12 h-12 text-muted-foreground/30 mb-3" />
|
<Puzzle className="w-12 h-12 text-muted-foreground/30 mb-3" />
|
||||||
<p className="text-sm text-muted-foreground mb-1">No plugins installed</p>
|
<p className="text-sm text-muted-foreground mb-1">No plugins available</p>
|
||||||
<p className="text-xs text-muted-foreground/70">Upload a plugin .zip file to get started</p>
|
<p className="text-xs text-muted-foreground/70">Your administrator has not deployed any plugins</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@@ -142,7 +96,6 @@ export function PluginsSettings() {
|
|||||||
controlsDisabled={!initialized}
|
controlsDisabled={!initialized}
|
||||||
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)}
|
|
||||||
onUpdateSettings={(settings) => updatePluginSettings(plugin.id, settings)}
|
onUpdateSettings={(settings) => updatePluginSettings(plugin.id, settings)}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@@ -153,33 +106,6 @@ export function PluginsSettings() {
|
|||||||
{!initialized && plugins.length > 0 && (
|
{!initialized && plugins.length > 0 && (
|
||||||
<p className="text-xs text-muted-foreground">Syncing plugin policy and managed state...</p>
|
<p className="text-xs text-muted-foreground">Syncing plugin policy and managed state...</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Upload */}
|
|
||||||
{pluginUploadsEnabled ? (
|
|
||||||
<SettingItem label="Upload Plugin" description="Install a new plugin from a .zip file">
|
|
||||||
<input
|
|
||||||
ref={fileInputRef}
|
|
||||||
type="file"
|
|
||||||
accept=".zip"
|
|
||||||
onChange={handleUpload}
|
|
||||||
className="hidden"
|
|
||||||
aria-label="Upload plugin file"
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => fileInputRef.current?.click()}
|
|
||||||
disabled={isUploading}
|
|
||||||
>
|
|
||||||
<Upload className="w-4 h-4 mr-1.5" />
|
|
||||||
{isUploading ? 'Installing...' : 'Upload .zip'}
|
|
||||||
</Button>
|
|
||||||
</SettingItem>
|
|
||||||
) : (
|
|
||||||
<SettingItem label="Upload Plugin" description="Install a new plugin from a .zip file">
|
|
||||||
<span className="text-xs text-muted-foreground">Disabled by administrator policy</span>
|
|
||||||
</SettingItem>
|
|
||||||
)}
|
|
||||||
</SettingsSection>
|
</SettingsSection>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -195,11 +121,10 @@ interface PluginCardProps {
|
|||||||
controlsDisabled: boolean;
|
controlsDisabled: boolean;
|
||||||
onToggleExpand: () => void;
|
onToggleExpand: () => void;
|
||||||
onToggle: () => void;
|
onToggle: () => void;
|
||||||
onUninstall: () => void;
|
|
||||||
onUpdateSettings: (settings: Record<string, unknown>) => void;
|
onUpdateSettings: (settings: Record<string, unknown>) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function PluginCard({ plugin, isExpanded, isForceEnabled, isManaged, needsApproval, controlsDisabled, onToggleExpand, onToggle, onUninstall, onUpdateSettings }: PluginCardProps) {
|
function PluginCard({ plugin, isExpanded, isForceEnabled, isManaged, needsApproval, controlsDisabled, onToggleExpand, onToggle, onUpdateSettings }: PluginCardProps) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
data-search-label={plugin.name}
|
data-search-label={plugin.name}
|
||||||
@@ -248,7 +173,7 @@ function PluginCard({ plugin, isExpanded, isForceEnabled, isManaged, needsApprov
|
|||||||
{isExpanded && (
|
{isExpanded && (
|
||||||
<div className="border-t border-border p-3 space-y-3">
|
<div className="border-t border-border p-3 space-y-3">
|
||||||
{isForceEnabled && (
|
{isForceEnabled && (
|
||||||
<p className="text-xs text-amber-600 dark:text-amber-400">This plugin is forced by an administrator and cannot be disabled or uninstalled.</p>
|
<p className="text-xs text-amber-600 dark:text-amber-400">This plugin is forced by an administrator and cannot be disabled.</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{needsApproval && (
|
{needsApproval && (
|
||||||
@@ -297,14 +222,6 @@ function PluginCard({ plugin, isExpanded, isForceEnabled, isManaged, needsApprov
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Uninstall */}
|
|
||||||
<div className="flex justify-end pt-2 border-t border-border">
|
|
||||||
<Button variant="destructive" size="sm" onClick={onUninstall} disabled={controlsDisabled || isForceEnabled}>
|
|
||||||
<Trash2 className="w-3.5 h-3.5 mr-1" />
|
|
||||||
Uninstall
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,21 +1,16 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, useRef, useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { useThemeStore } from '@/stores/theme-store';
|
import { useThemeStore } from '@/stores/theme-store';
|
||||||
import { SettingsSection, SettingItem } from './settings-section';
|
import { SettingsSection } from './settings-section';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { Upload, Trash2, Check, Palette, Lock } from 'lucide-react';
|
import { Check, Palette, Lock } from 'lucide-react';
|
||||||
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 { usePolicyStore } from '@/stores/policy-store';
|
import { usePolicyStore } from '@/stores/policy-store';
|
||||||
|
|
||||||
export function ThemesSettings() {
|
export function ThemesSettings() {
|
||||||
const { installedThemes, activeThemeId, installTheme, uninstallTheme, activateTheme } = useThemeStore();
|
const { installedThemes, activeThemeId, activateTheme } = useThemeStore();
|
||||||
const [isUploading, setIsUploading] = useState(false);
|
const { isThemeDisabled, getThemePolicy, getForcedThemeId, isThemeForceEnabled } = usePolicyStore();
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
||||||
const { isFeatureEnabled, isThemeDisabled, getThemePolicy, getForcedThemeId, isThemeForceEnabled } = usePolicyStore();
|
|
||||||
const canUpload = isFeatureEnabled('userThemesEnabled');
|
|
||||||
const themePolicy = getThemePolicy();
|
const themePolicy = getThemePolicy();
|
||||||
const forcedThemeId = getForcedThemeId(installedThemes.map((theme) => theme.id));
|
const forcedThemeId = getForcedThemeId(installedThemes.map((theme) => theme.id));
|
||||||
|
|
||||||
@@ -40,30 +35,6 @@ export function ThemesSettings() {
|
|||||||
}
|
}
|
||||||
}, [activeThemeId, activateTheme, forcedThemeId, installedThemes, isThemeDisabled]);
|
}, [activeThemeId, activateTheme, forcedThemeId, installedThemes, isThemeDisabled]);
|
||||||
|
|
||||||
const handleUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
||||||
const file = e.target.files?.[0];
|
|
||||||
if (!file) return;
|
|
||||||
|
|
||||||
setIsUploading(true);
|
|
||||||
try {
|
|
||||||
const result = await installTheme(file);
|
|
||||||
if (result.success) {
|
|
||||||
toast.success('Theme installed');
|
|
||||||
if (result.warnings?.length) {
|
|
||||||
toast.warning('Theme warnings', { message: result.warnings.join('\n') });
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
toast.error('Theme installation failed', { message: result.error });
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
toast.error('Theme installation failed', { message: err instanceof Error ? err.message : 'Unknown error' });
|
|
||||||
} finally {
|
|
||||||
setIsUploading(false);
|
|
||||||
// Reset file input
|
|
||||||
if (fileInputRef.current) fileInputRef.current.value = '';
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleActivate = (id: string | null) => {
|
const handleActivate = (id: string | null) => {
|
||||||
if (forcedThemeId && id !== forcedThemeId) {
|
if (forcedThemeId && id !== forcedThemeId) {
|
||||||
const forcedTheme = installedThemes.find((theme) => theme.id === forcedThemeId);
|
const forcedTheme = installedThemes.find((theme) => theme.id === forcedThemeId);
|
||||||
@@ -75,19 +46,8 @@ export function ThemesSettings() {
|
|||||||
toast.success(id ? 'Theme activated' : 'Default theme restored');
|
toast.success(id ? 'Theme activated' : 'Default theme restored');
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleUninstall = (theme: InstalledTheme) => {
|
|
||||||
if (theme.builtIn) return;
|
|
||||||
if (theme.id === forcedThemeId || theme.forceEnabled || isThemeForceEnabled(theme.id)) {
|
|
||||||
toast.info(`Theme "${theme.name}" is forced by admin and cannot be removed`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
uninstallTheme(theme.id);
|
|
||||||
toast.success('Theme removed');
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SettingsSection title="Themes" description="Customize the appearance with color themes. Upload .zip theme files or activate built-in presets." experimental experimentalDescription="Themes is an experimental feature. Custom themes may not cover all UI elements, and theme formats could change in future updates. Built-in presets are stable, but uploaded themes may require updates after application upgrades.">
|
<SettingsSection title="Themes" description="Choose from themes deployed by your administrator and built-in presets." experimental experimentalDescription="Themes is an experimental feature. Custom themes may not cover all UI elements, and theme formats could change in future updates. Built-in presets are stable.">
|
||||||
|
|
||||||
{forcedThemeId && (
|
{forcedThemeId && (
|
||||||
<div className="mb-4 rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-800 dark:border-amber-900/40 dark:bg-amber-950/20 dark:text-amber-300">
|
<div className="mb-4 rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-800 dark:border-amber-900/40 dark:bg-amber-950/20 dark:text-amber-300">
|
||||||
@@ -124,34 +84,10 @@ export function ThemesSettings() {
|
|||||||
disabled={Boolean(forcedThemeId) && !isForceEnabled}
|
disabled={Boolean(forcedThemeId) && !isForceEnabled}
|
||||||
variants={theme.variants}
|
variants={theme.variants}
|
||||||
onActivate={() => handleActivate(theme.id)}
|
onActivate={() => handleActivate(theme.id)}
|
||||||
onRemove={!theme.builtIn && !isForceEnabled ? () => handleUninstall(theme) : undefined}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</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}
|
|
||||||
type="file"
|
|
||||||
accept=".zip"
|
|
||||||
onChange={handleUpload}
|
|
||||||
className="hidden"
|
|
||||||
aria-label="Upload theme file"
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => fileInputRef.current?.click()}
|
|
||||||
disabled={isUploading}
|
|
||||||
>
|
|
||||||
<Upload className="w-4 h-4 mr-1.5" />
|
|
||||||
{isUploading ? 'Installing...' : 'Upload .zip'}
|
|
||||||
</Button>
|
|
||||||
</SettingItem>
|
|
||||||
)}
|
|
||||||
</SettingsSection>
|
</SettingsSection>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -169,10 +105,9 @@ interface ThemeCardProps {
|
|||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
variants?: ('light' | 'dark')[];
|
variants?: ('light' | 'dark')[];
|
||||||
onActivate: () => void;
|
onActivate: () => void;
|
||||||
onRemove?: () => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function ThemeCard({ name, author, preview, isActive, isDefault, isForceEnabled, disabled, variants, onActivate, onRemove }: ThemeCardProps) {
|
function ThemeCard({ name, author, preview, isActive, isDefault, isForceEnabled, disabled, variants, onActivate }: ThemeCardProps) {
|
||||||
return (
|
return (
|
||||||
<div data-search-label={name} className="relative">
|
<div data-search-label={name} className="relative">
|
||||||
<button
|
<button
|
||||||
@@ -224,18 +159,6 @@ function ThemeCard({ name, author, preview, isActive, isDefault, isForceEnabled,
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Remove button */}
|
|
||||||
{onRemove && !isActive && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onRemove}
|
|
||||||
className="absolute top-2 right-2 p-1 rounded-md hover:bg-destructive/10 text-muted-foreground hover:text-destructive transition-colors"
|
|
||||||
title="Remove theme"
|
|
||||||
>
|
|
||||||
<Trash2 className="w-3.5 h-3.5" />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user