'use client'; import { useState, useEffect, useRef } from 'react'; import { useLocale, 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'; import { lookup } from '@/lib/plugin-sandbox/plugin-i18n'; import { forgetCredentials, listConnectedAccounts, type ConnectedAccount } from '@/lib/plugin-sandbox/connect-dialog'; const STATUS_COLORS: Record = { 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(null); const [installing, setInstalling] = useState(false); const [installError, setInstallError] = useState(null); const [removing, setRemoving] = useState(null); const fileInput = useRef(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 ( {uploadsAllowed && (
{requireApproval ? t('install_hint_approval') : t('install_hint')} handleInstall(e.target.files?.[0])} />
{installError && (
{installError}
)}
)} {plugins.length === 0 ? (

{t('empty_title')}

{demoMode ? t('empty_demo') : uploadsAllowed ? t('empty_uploads') : t('empty_admin')}

) : (
{plugins.map(plugin => ( setExpandedPlugin(expandedPlugin === plugin.id ? null : plugin.id)} onToggle={() => handleToggle(plugin)} onRemove={() => setRemoving(plugin)} onUpdateSettings={(settings) => updatePluginSettings(plugin.id, settings)} /> ))}
)} {!initialized && plugins.length > 0 && (

{t('syncing')}

)} 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" />
); } // ─── Plugin Card ───────────────────────────────────────────── interface PluginCardProps { plugin: InstalledPlugin; isExpanded: boolean; isForceEnabled: boolean; controlsDisabled: boolean; onToggleExpand: () => void; onToggle: () => void; onRemove: () => void; onUpdateSettings: (settings: Record) => void; } function PluginCard({ plugin, isExpanded, isForceEnabled, controlsDisabled, onToggleExpand, onToggle, onRemove, onUpdateSettings }: PluginCardProps) { const t = useTranslations('plugins.settings'); const locale = useLocale(); 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 (
{isExpanded && (
{isForceEnabled &&

{t('forced_note')}

} {plugin.description &&

{plugin.description}

} {errorText && (
{errorText}
)}
{isManaged ? t('permissions_managed') : t('permissions')}
{plugin.settingsSchema && Object.keys(plugin.settingsSchema).length > 0 && (
{t('settings')} {Object.entries(plugin.settingsSchema).map(([key, schema]) => ( (plugin.locales ? lookup(plugin.locales, locale, text) ?? text : text)} value={effectiveSettings(plugin)[key]} onChange={(value) => onUpdateSettings({ [key]: value })} /> ))}
)} {plugin.credentials && Object.values(plugin.credentials).some((c) => c.scope === 'user') && ( )} {!isManaged && (
)}
)}
); } // ─── Connected accounts ────────────────────────────────────── /** Accounts the user connected for this plugin through the secret proxy. */ function ConnectedAccounts({ pluginId }: { pluginId: string }) { const t = useTranslations('plugins.settings'); const [accounts, setAccounts] = useState(null); useEffect(() => { let live = true; void listConnectedAccounts(pluginId).then((list) => { if (live) setAccounts(list); }); return () => { live = false; }; }, [pluginId]); if (!accounts || accounts.length === 0) return null; const disconnect = async (account: ConnectedAccount) => { if (await forgetCredentials(pluginId, account.credentialId)) { setAccounts((list) => (list ?? []).filter((a) => a.credentialId !== account.credentialId)); } else { toast.error(t('disconnect_failed', { label: account.label })); } }; return (
{t('accounts_heading')} {accounts.map((account) => { const host = new URL(account.origin).host; return (
{account.connected ? t('account_on', { label: account.username ? `${account.label} (${account.username})` : account.label, host }) : t('account_waiting', { label: account.label, host })}
); })}
); } // ─── Auto-generated Setting Field ──────────────────────────── interface PluginSettingFieldProps { fieldKey: string; schema: SettingFieldSchema; /** * A label, description or option that names a key in the plugin's own * translations (manifest `locales`) is shown translated; other text as is. */ translate?: (text: string) => string; value: unknown; onChange: (value: unknown) => void; } function PluginSettingField({ fieldKey, schema: raw, value, onChange, translate = (text) => text }: PluginSettingFieldProps) { const inputId = `plugin-setting-${fieldKey}`; const schema = { ...raw, label: translate(raw.label), ...(raw.description ? { description: translate(raw.description) } : {}) }; switch (schema.type) { case 'boolean': return (
{schema.label} {schema.description &&

{schema.description}

}
onChange(v)} ariaLabel={schema.label} />
); case 'select': return (
{schema.description &&

{schema.description}

}
); case 'string': return (
{schema.description &&

{schema.description}

} onChange(e.target.value)} className="mt-1 w-full rounded border border-border bg-background px-2 py-1 text-xs text-foreground" />
); case 'number': return (
{schema.description &&

{schema.description}

}
onChange(Number(e.target.value))} className="w-20 rounded border border-border bg-background px-2 py-1 text-xs text-foreground" />
); default: return null; } }