Files
webmail/components/settings/plugins-settings.tsx
T

412 lines
16 KiB
TypeScript

'use client';
import { useState, useEffect, useRef } from 'react';
import { useTranslations } from 'next-intl';
import { usePluginStore } from '@/stores/plugin-store';
import { usePolicyStore } from '@/stores/policy-store';
import { useConfig } from '@/hooks/use-config';
import { SettingsSection, ToggleSwitch } from './settings-section';
import { cn } from '@/lib/utils';
import { AlertTriangle, Puzzle, Lock, Server, Upload, Trash2, Loader2 } from '@/components/icons';
import { toast } from '@/stores/toast-store';
import { Button } from '@/components/ui/button';
import { ConfirmDialog } from '@/components/ui/confirm-dialog';
import { PluginPermissionList } from '@/components/plugins/plugin-permission-list';
import type { InstalledPlugin, PluginStatus, SettingFieldSchema } from '@/lib/plugin-types';
import { effectiveSettings } from '@/lib/plugin-settings';
const STATUS_COLORS: Record<PluginStatus, string> = {
installed: 'bg-muted text-muted-foreground',
enabled: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400',
running: 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400',
disabled: 'bg-muted text-muted-foreground',
error: 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400',
};
/** Errors the store records as codes, so they can be shown translated. */
const ERROR_CODES = new Set(['user-plugins-off', 'approval-unavailable', 'approval-denied', 'approval-pending', 'auto-disabled']);
export function PluginsSettings() {
const t = useTranslations('plugins.settings');
const { plugins, installPlugin, uninstallPlugin, enablePlugin, disablePlugin, updatePluginSettings, initializePlugins, initialized } = usePluginStore();
const { isFeatureEnabled, isPluginForceEnabled, fetchPolicy, loaded } = usePolicyStore();
const { demoMode } = useConfig();
const [expandedPlugin, setExpandedPlugin] = useState<string | null>(null);
const [installing, setInstalling] = useState(false);
const [installError, setInstallError] = useState<string | null>(null);
const [removing, setRemoving] = useState<InstalledPlugin | null>(null);
const fileInput = useRef<HTMLInputElement>(null);
useEffect(() => {
if (!loaded) {
fetchPolicy();
}
initializePlugins();
}, [fetchPolicy, initializePlugins, loaded]);
// Listen for "expand this plugin" events fired by the settings search when
// the user clicks a plugin-setting sub-result. Expanding the card mounts
// the per-field rows so the highlight effect can find them.
useEffect(() => {
const handler = (e: Event) => {
const id = (e as CustomEvent<{ pluginId: string }>).detail?.pluginId;
if (id) setExpandedPlugin(id);
};
window.addEventListener('settings-plugin-expand', handler);
return () => window.removeEventListener('settings-plugin-expand', handler);
}, []);
if (!isFeatureEnabled('pluginsEnabled')) {
return null;
}
const uploadsAllowed = isFeatureEnabled('pluginsUploadEnabled') && !demoMode;
const requireApproval = isFeatureEnabled('requirePluginApproval');
const handleToggle = async (plugin: InstalledPlugin) => {
if (!initialized) return;
if (plugin.forceEnabled || isPluginForceEnabled(plugin.id)) {
toast.info(t('toast_forced', { name: plugin.name }));
return;
}
if (plugin.enabled) {
disablePlugin(plugin.id);
toast.info(t('toast_disabled', { name: plugin.name }));
return;
}
await enablePlugin(plugin.id);
const after = usePluginStore.getState().plugins.find((p) => p.id === plugin.id);
if (after?.enabled) {
toast.success(t('toast_enabled', { name: plugin.name }));
} else if (after?.error === 'approval-pending') {
toast.info(t('toast_approval_requested', { name: plugin.name }));
}
};
const handleInstall = async (file: File | undefined) => {
if (!file) return;
setInstalling(true);
setInstallError(null);
try {
const result = await installPlugin(file);
if (!result.success) {
setInstallError(
result.reason === 'uploads-disabled' ? t('install_uploads_off')
: result.reason === 'managed-id' ? t('install_managed_id')
: t('install_invalid', { details: result.error ?? '' }),
);
return;
}
toast.success(result.approvalStatus === 'pending'
? t('toast_installed_pending')
: t('toast_installed'));
} finally {
setInstalling(false);
if (fileInput.current) fileInput.current.value = '';
}
};
return (
<SettingsSection
title={t('title')}
description={demoMode ? t('description_demo') : uploadsAllowed ? t('description_uploads') : t('description')}
>
{uploadsAllowed && (
<div className="space-y-2">
<div className="flex flex-wrap items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => fileInput.current?.click()}
disabled={installing}
>
{installing ? <Loader2 className="h-4 w-4 animate-spin" /> : <Upload className="h-4 w-4" />}
{t('install_button')}
</Button>
<span className="text-xs text-muted-foreground">
{requireApproval ? t('install_hint_approval') : t('install_hint')}
</span>
<input
ref={fileInput}
type="file"
accept=".zip,application/zip"
className="sr-only"
aria-label={t('install_button')}
onChange={(e) => handleInstall(e.target.files?.[0])}
/>
</div>
{installError && (
<div role="alert" className="flex items-start gap-2 rounded-md bg-destructive/10 p-2 text-xs text-destructive">
<AlertTriangle className="mt-0.5 h-3.5 w-3.5 flex-shrink-0" />
<span className="break-words">{installError}</span>
</div>
)}
</div>
)}
{plugins.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-center">
<Puzzle className="mb-3 h-12 w-12 text-muted-foreground/30" />
<p className="mb-1 text-sm text-muted-foreground">{t('empty_title')}</p>
<p className="text-xs text-muted-foreground/70">
{demoMode ? t('empty_demo') : uploadsAllowed ? t('empty_uploads') : t('empty_admin')}
</p>
</div>
) : (
<div className="space-y-2">
{plugins.map(plugin => (
<PluginCard
key={plugin.id}
plugin={plugin}
isExpanded={expandedPlugin === plugin.id}
isForceEnabled={plugin.forceEnabled || isPluginForceEnabled(plugin.id)}
controlsDisabled={!initialized}
onToggleExpand={() => setExpandedPlugin(expandedPlugin === plugin.id ? null : plugin.id)}
onToggle={() => handleToggle(plugin)}
onRemove={() => setRemoving(plugin)}
onUpdateSettings={(settings) => updatePluginSettings(plugin.id, settings)}
/>
))}
</div>
)}
{!initialized && plugins.length > 0 && (
<p className="text-xs text-muted-foreground">{t('syncing')}</p>
)}
<ConfirmDialog
isOpen={removing !== null}
onClose={() => setRemoving(null)}
onConfirm={() => {
if (removing) {
uninstallPlugin(removing.id);
toast.info(t('toast_removed', { name: removing.name }));
}
setRemoving(null);
}}
title={t('remove_title', { name: removing?.name ?? '' })}
message={t('remove_message')}
confirmText={t('remove_confirm')}
variant="destructive"
/>
</SettingsSection>
);
}
// ─── Plugin Card ─────────────────────────────────────────────
interface PluginCardProps {
plugin: InstalledPlugin;
isExpanded: boolean;
isForceEnabled: boolean;
controlsDisabled: boolean;
onToggleExpand: () => void;
onToggle: () => void;
onRemove: () => void;
onUpdateSettings: (settings: Record<string, unknown>) => void;
}
function PluginCard({ plugin, isExpanded, isForceEnabled, controlsDisabled, onToggleExpand, onToggle, onRemove, onUpdateSettings }: PluginCardProps) {
const t = useTranslations('plugins.settings');
const isManaged = Boolean(plugin.managed);
const awaiting = !isManaged && plugin.approvalStatus === 'pending';
const denied = !isManaged && plugin.approvalStatus === 'denied';
const errorText = plugin.error
? ERROR_CODES.has(plugin.error) ? t(`error_${plugin.error.replace(/-/g, '_')}`) : plugin.error
: null;
return (
<div
data-search-label={plugin.name}
className={cn(
'rounded-lg border transition-colors',
plugin.status === 'error' ? 'border-destructive/40' : 'border-border',
)}
>
<div className="flex items-center gap-3 p-3">
<button
type="button"
className="min-w-0 flex-1 text-left"
onClick={onToggleExpand}
aria-expanded={isExpanded}
>
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
<span className="truncate text-sm font-medium text-foreground">{plugin.name}</span>
<span className={cn('rounded-full px-1.5 py-0.5 text-[10px] font-medium', STATUS_COLORS[plugin.status])}>
{t(`status_${plugin.status}`)}
</span>
{isForceEnabled && (
<span className="flex items-center gap-0.5 rounded-full bg-amber-100 px-1.5 py-0.5 text-[10px] font-medium text-amber-700 dark:bg-amber-900/30 dark:text-amber-400">
<Lock className="h-2.5 w-2.5" /> {t('badge_forced')}
</span>
)}
<span className={cn(
'flex items-center gap-0.5 rounded-full px-1.5 py-0.5 text-[10px] font-medium',
isManaged
? 'bg-cyan-100 text-cyan-700 dark:bg-cyan-900/30 dark:text-cyan-400'
: 'bg-muted text-muted-foreground',
)}>
{isManaged && <Server className="h-2.5 w-2.5" />} {isManaged ? t('badge_managed') : t('badge_yours')}
</span>
{awaiting && (
<span className="rounded-full bg-orange-100 px-1.5 py-0.5 text-[10px] font-medium text-orange-700 dark:bg-orange-900/30 dark:text-orange-400">
{t('badge_awaiting')}
</span>
)}
</div>
<div className="mt-0.5 flex items-center gap-2">
<span className="truncate text-xs text-muted-foreground">{plugin.author}</span>
<span className="text-xs text-muted-foreground/70">v{plugin.version}</span>
</div>
</button>
<div className="flex flex-shrink-0 items-center gap-2">
<ToggleSwitch
checked={plugin.enabled}
onChange={onToggle}
disabled={controlsDisabled || isForceEnabled || denied}
ariaLabel={t('toggle_label', { name: plugin.name })}
/>
</div>
</div>
{isExpanded && (
<div className="space-y-3 border-t border-border p-3">
{isForceEnabled && <p className="text-xs text-amber-600 dark:text-amber-400">{t('forced_note')}</p>}
{plugin.description && <p className="text-xs text-muted-foreground">{plugin.description}</p>}
{errorText && (
<div className="flex items-start gap-2 rounded bg-destructive/10 p-2 text-xs text-destructive">
<AlertTriangle className="mt-0.5 h-3.5 w-3.5 flex-shrink-0" />
<span className="break-words">{errorText}</span>
</div>
)}
<div>
<span className="text-xs font-medium text-foreground">
{isManaged ? t('permissions_managed') : t('permissions')}
</span>
<div className="mt-1.5">
<PluginPermissionList
permissions={plugin.permissions}
httpOrigins={plugin.httpOrigins}
apiPostPaths={plugin.apiPostPaths}
compact
/>
</div>
</div>
{plugin.settingsSchema && Object.keys(plugin.settingsSchema).length > 0 && (
<div className="space-y-2">
<span className="text-xs font-medium text-foreground">{t('settings')}</span>
{Object.entries(plugin.settingsSchema).map(([key, schema]) => (
<PluginSettingField
key={key}
fieldKey={key}
schema={schema}
value={effectiveSettings(plugin)[key]}
onChange={(value) => onUpdateSettings({ [key]: value })}
/>
))}
</div>
)}
{!isManaged && (
<div className="flex justify-end">
<Button variant="ghost" size="sm" onClick={onRemove} className="text-destructive hover:text-destructive">
<Trash2 className="h-4 w-4" />
{t('remove')}
</Button>
</div>
)}
</div>
)}
</div>
);
}
// ─── Auto-generated Setting Field ────────────────────────────
interface PluginSettingFieldProps {
fieldKey: string;
schema: SettingFieldSchema;
value: unknown;
onChange: (value: unknown) => void;
}
function PluginSettingField({ fieldKey, schema, value, onChange }: PluginSettingFieldProps) {
const inputId = `plugin-setting-${fieldKey}`;
switch (schema.type) {
case 'boolean':
return (
<div data-search-label={schema.label} className="flex items-center justify-between gap-3">
<div>
<span className="text-xs text-foreground">{schema.label}</span>
{schema.description && <p className="text-[10px] text-muted-foreground">{schema.description}</p>}
</div>
<ToggleSwitch checked={value as boolean} onChange={(v) => onChange(v)} ariaLabel={schema.label} />
</div>
);
case 'select':
return (
<div data-search-label={schema.label} className="flex items-center justify-between gap-3">
<div>
<label htmlFor={inputId} className="text-xs text-foreground">{schema.label}</label>
{schema.description && <p className="text-[10px] text-muted-foreground">{schema.description}</p>}
</div>
<select
id={inputId}
value={String(value)}
onChange={(e) => onChange(e.target.value)}
className="rounded border border-border bg-background px-2 py-1 text-xs text-foreground"
>
{(Array.isArray(schema.options) ? schema.options : []).map(opt => (
<option key={opt} value={opt}>{opt}</option>
))}
</select>
</div>
);
case 'string':
return (
<div data-search-label={schema.label}>
<label htmlFor={inputId} className="text-xs text-foreground">{schema.label}</label>
{schema.description && <p className="text-[10px] text-muted-foreground">{schema.description}</p>}
<input
id={inputId}
type="text"
value={String(value ?? '')}
onChange={(e) => onChange(e.target.value)}
className="mt-1 w-full rounded border border-border bg-background px-2 py-1 text-xs text-foreground"
/>
</div>
);
case 'number':
return (
<div data-search-label={schema.label} className="flex items-center justify-between gap-3">
<div>
<label htmlFor={inputId} className="text-xs text-foreground">{schema.label}</label>
{schema.description && <p className="text-[10px] text-muted-foreground">{schema.description}</p>}
</div>
<input
id={inputId}
type="number"
value={Number(value ?? schema.default ?? 0)}
min={schema.min}
max={schema.max}
onChange={(e) => onChange(Number(e.target.value))}
className="w-20 rounded border border-border bg-background px-2 py-1 text-xs text-foreground"
/>
</div>
);
default:
return null;
}
}