import { useEffect, useState } from 'react' import { Save, Loader2, Link2, Unlink, CheckCircle } from 'lucide-react' import PluginIcon from '../shared/PluginIcon' import PluginFrame from '../Plugins/PluginFrame' import { pluginsApi, type PluginUserSettingField, type PluginAction } from '../../api/client' import { usePluginStore } from '../../store/pluginStore' import { useToast } from '../shared/Toast' import { useTranslation } from '../../i18n' import PluginActivityPanel from './PluginActivityPanel' import { seedSettingsValues, findMissingRequired, settingsPatch } from '../Plugins/settingsForm' /** Host-brokered OAuth: a Connect/Disconnect control. The host runs the whole flow + * holds the tokens; this only triggers connect (redirect to the provider) / disconnect. */ function PluginOAuthSection({ id, state, setState }: { id: string state: { configured: boolean; connected: boolean } | null setState: (s: { configured: boolean; connected: boolean }) => void }) { const { t } = useTranslation() const toast = useToast() const [busy, setBusy] = useState(false) if (!state?.configured) return null const connect = async () => { setBusy(true) try { const { authorizeUrl } = await pluginsApi.oauthConnect(id) window.location.href = authorizeUrl // hand off to the provider; returns to /settings } catch { toast.error(t('common.error')); setBusy(false) } } const disconnect = async () => { setBusy(true) try { await pluginsApi.oauthDisconnect(id); setState({ ...state, connected: false }) } catch { toast.error(t('common.error')) } finally { setBusy(false) } } return (
{state.connected ? <> {t('settings.plugins.oauth.connected')} : <>{t('settings.plugins.oauth.notConnected')}} {state.connected ? : }
) } /** * A user's own per-plugin settings (#plugins). The host renders the plugin's * declared `scope:'user'` fields as an editable form — a plugin never ships markup * here; the field list is trusted, validated manifest data. Secrets stay write-only * (masked, never echoed back). One form per active plugin that declares user fields. */ function PluginSettingsForm({ id, name, icon }: { id: string; name: string; icon: string | null }) { const { t } = useTranslation() const toast = useToast() const [fields, setFields] = useState(null) const [values, setValues] = useState>({}) const [saving, setSaving] = useState(false) const [oauth, setOauth] = useState<{ configured: boolean; connected: boolean } | null>(null) const [actions, setActions] = useState([]) const [running, setRunning] = useState(null) const [actionResult, setActionResult] = useState>({}) useEffect(() => { let alive = true pluginsApi.userSettings(id) .then(r => { if (!alive) return setFields(r.fields) setActions(r.actions ?? []) setValues(seedSettingsValues(r.fields, r.config)) }) .catch(() => { if (alive) setFields([]) }) pluginsApi.oauthStatus(id).then(s => { if (alive) setOauth(s) }).catch(() => { if (alive) setOauth(null) }) return () => { alive = false } }, [id]) const hasFields = (fields?.length ?? 0) > 0 // Show the card if the plugin has user fields, actions, OR an OAuth connection to offer. if (fields === null || (!hasFields && actions.length === 0 && !oauth?.configured)) return null // An action runs AS the caller, so it sees the values they just saved — run the save // first if the form is dirty would be nicer, but keeping it explicit is less surprising. const runAction = async (a: PluginAction) => { if (a.danger && !window.confirm(t('settings.plugins.actions.confirm'))) return setRunning(a.key) try { const res = await pluginsApi.runAction(id, a.key) setActionResult(prev => ({ ...prev, [a.key]: res })) } catch { setActionResult(prev => ({ ...prev, [a.key]: { ok: false, message: t('common.error') } })) } finally { setRunning(null) } } const save = async () => { const missing = findMissingRequired(fields, values) if (missing) { toast.error(t('settings.plugins.requiredMissing', { field: missing.label || missing.key })) return } setSaving(true) try { const r = await pluginsApi.saveUserSettings(id, settingsPatch(fields, values)) setValues(seedSettingsValues(fields, r.config)) toast.success(t('settings.plugins.saved')) } catch (e) { // A 4xx names what the server refused (a required field it knows about and this // stale field list doesn't); a 5xx body is not for the user. const err = e as { response?: { status?: number; data?: { error?: string } } } const refused = err.response?.status && err.response.status < 500 ? err.response.data?.error : undefined toast.error(refused || t('common.error')) } finally { setSaving(false) } } return (

{name}

{(fields ?? []).map(f => ( ))}
{hasFields && ( )} {actions.length > 0 && (
{t('settings.plugins.actions')}
{actions.map(a => { const res = actionResult[a.key] return (
{a.hint && {a.hint}} {res && ( {res.message || (res.ok ? t('common.success') : t('common.error'))} )}
) })}
)}
) } /** * A plugin's OWN settings surface (capabilities.settingsUi): its sandboxed * client/settings.html framed inside the familiar settings card. Unlike the * declared-fields form above, the plugin renders this itself — same opaque-origin * sandbox and postMessage bridge as its widget, so it can only reach its own * routes. The frame auto-sizes via trek:resize like a dashboard widget. */ function PluginSettingsUiCard({ id, name, icon }: { id: string; name: string; icon: string | null }) { return (

{name}

{/* min-height covers the beat before the frame's first trek:resize lands. color-scheme light on the frame element matches the sandboxed document's default ("normal"): mismatched schemes make Chromium paint an opaque white canvas behind the transparent frame (same trap .hero-overlay-frame guards against), which glares in dark mode. */}
) } export default function PluginSettingsTab() { const { t } = useTranslation() const plugins = usePluginStore(s => s.plugins) return (

{t('settings.plugins.title')}

{t('settings.plugins.subtitle')}

{plugins.length === 0 ?

{t('settings.plugins.empty')}

: plugins.map(p => (
{p.settingsUi && }
))}
) }