)
}
/**
* 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 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. */}