mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 13:36:48 +00:00
1e6ab069cd
* feat(plugin-sdk): settings-field default + unknown-attribute warning
* feat(shared): default on the plugin settings-field contract
* feat(server): persist and serve plugin settings-field defaults
* feat(client): pre-fill plugin settings defaults in the admin and user forms
* feat(server): enforce required plugin settings on both save paths
* feat(client): block plugin-settings save on an empty required field
* feat(server): resolve plugin settings defaults at runtime
A settings-field `default` was stored and served to the form but never
reached the plugin: the child's init envelope, `ctx.settings.get()`, the
channel dispatch's readAll and the OAuth provider config all read the
stored blob only. Fold `stored ?? default` in on every path that hands
config to a plugin (settings-defaults.ts), and let a default satisfy
`required` on both save gates — refusing a save the runtime would have
accepted was a contradiction.
Also: one `isFilled` rule shared by the save gate and `hasRequired`
(checkbox exempt, whitespace empty); the install parser drops a
non-boolean checkbox default or a select default outside its options,
matching what the SDK already refuses; export SETTING_FIELD_KEYS so the
SDK's mirror can be parity-tested.
* feat(client): share the plugin settings form rules across all three forms
The mobile user settings screen was a third consumer of the fields/config
contract that never got `default` pre-fill or the required check, and
the seed/patch loops lived in five hand copies. Extract
seedSettingsValues / findMissingRequired / settingsPatch into one module
and use it from the admin instance form, the desktop user tab and the
mobile screen. The user forms now surface a 4xx `{ error }` from the
server (the required-field refusal) instead of the generic toast.
* feat(plugin-sdk): seed dev fixtures from manifest defaults
`trek-plugin dev` handed the plugin only the fixture values, so a plugin
relying on its manifest defaults read undefined in dev and only in dev.
Seed ctx.config / ctx.settings from the declared defaults (a fixture
value still wins) via a new exported settingDefaults(manifest, scope).
SETTING_FIELD_KEYS is now parity-tested against the host's list.
* docs(plugins): document settings defaults as runtime fallbacks
A `default` is the field's effective value wherever nobody set one, not
just a form pre-fill; `required` is enforced on both ends; unknown
attributes are dropped at install and warned on by validate. Add a
Settings section with the attribute table to the SDK README and update
the ctx type comments, the shared schema, and the wiki guide.
* add missing exports
265 lines
12 KiB
TypeScript
265 lines
12 KiB
TypeScript
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 (
|
|
<div className="mt-4 flex items-center justify-between rounded-lg border border-border bg-surface-secondary px-3 py-2">
|
|
<span className="flex items-center gap-2 text-sm text-content-secondary">
|
|
{state.connected
|
|
? <><CheckCircle className="w-4 h-4 text-success" /> {t('settings.plugins.oauth.connected')}</>
|
|
: <>{t('settings.plugins.oauth.notConnected')}</>}
|
|
</span>
|
|
{state.connected
|
|
? <button type="button" onClick={disconnect} disabled={busy} className="inline-flex items-center gap-1.5 rounded-lg border border-border px-3 py-1.5 text-sm font-semibold text-content disabled:opacity-60"><Unlink className="w-4 h-4" />{t('settings.plugins.oauth.disconnect')}</button>
|
|
: <button type="button" onClick={connect} disabled={busy} className="inline-flex items-center gap-1.5 rounded-lg bg-accent px-3 py-1.5 text-sm font-semibold text-white disabled:opacity-60">{busy ? <Loader2 className="w-4 h-4 animate-spin" /> : <Link2 className="w-4 h-4" />}{t('settings.plugins.oauth.connect')}</button>}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
|
|
/**
|
|
* 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<PluginUserSettingField[] | null>(null)
|
|
const [values, setValues] = useState<Record<string, string | boolean>>({})
|
|
const [saving, setSaving] = useState(false)
|
|
const [oauth, setOauth] = useState<{ configured: boolean; connected: boolean } | null>(null)
|
|
const [actions, setActions] = useState<PluginAction[]>([])
|
|
const [running, setRunning] = useState<string | null>(null)
|
|
const [actionResult, setActionResult] = useState<Record<string, { ok: boolean; message?: string }>>({})
|
|
|
|
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 (
|
|
<div className="rounded-xl border border-border bg-surface p-5">
|
|
<div className="flex items-center gap-2 mb-4">
|
|
<PluginIcon name={icon} className="w-4 h-4 text-content-secondary" />
|
|
<h3 className="text-sm font-semibold text-content">{name}</h3>
|
|
</div>
|
|
<div className="space-y-4">
|
|
{(fields ?? []).map(f => (
|
|
<label key={f.key} className="block">
|
|
<span className="block text-sm font-medium text-content-secondary mb-1">
|
|
{f.label || f.key}{f.required && <span className="text-danger"> *</span>}
|
|
</span>
|
|
{f.input_type === 'checkbox' ? (
|
|
<input
|
|
type="checkbox"
|
|
checked={values[f.key] === true}
|
|
onChange={e => setValues(v => ({ ...v, [f.key]: e.target.checked }))}
|
|
className="h-4 w-4 rounded border-border"
|
|
/>
|
|
) : f.input_type === 'select' && f.options ? (
|
|
<select
|
|
value={String(values[f.key] ?? '')}
|
|
onChange={e => setValues(v => ({ ...v, [f.key]: e.target.value }))}
|
|
className="w-full rounded-lg border border-border bg-surface-secondary px-3 py-2 text-sm text-content"
|
|
>
|
|
<option value="">—</option>
|
|
{f.options.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
|
|
</select>
|
|
) : (
|
|
<input
|
|
type={f.secret ? 'password' : (f.input_type === 'number' ? 'number' : 'text')}
|
|
value={String(values[f.key] ?? '')}
|
|
placeholder={f.placeholder || ''}
|
|
autoComplete={f.secret ? 'new-password' : 'off'}
|
|
onChange={e => setValues(v => ({ ...v, [f.key]: e.target.value }))}
|
|
className="w-full rounded-lg border border-border bg-surface-secondary px-3 py-2 text-sm text-content"
|
|
/>
|
|
)}
|
|
{f.hint && <span className="block text-xs text-content-muted mt-1">{f.hint}</span>}
|
|
</label>
|
|
))}
|
|
</div>
|
|
{hasFields && (
|
|
<button type="button"
|
|
onClick={save}
|
|
disabled={saving}
|
|
className="mt-4 inline-flex items-center gap-2 rounded-lg bg-accent px-4 py-2 text-sm font-semibold text-white disabled:opacity-60"
|
|
>
|
|
{saving ? <Loader2 className="w-4 h-4 animate-spin" /> : <Save className="w-4 h-4" />}
|
|
{t('common.save')}
|
|
</button>
|
|
)}
|
|
{actions.length > 0 && (
|
|
<div className="mt-4 border-t border-border pt-4">
|
|
<span className="block text-xs font-semibold uppercase tracking-wide text-content-muted mb-2">
|
|
{t('settings.plugins.actions')}
|
|
</span>
|
|
<div className="flex flex-col gap-2">
|
|
{actions.map(a => {
|
|
const res = actionResult[a.key]
|
|
return (
|
|
<div key={a.key} className="flex flex-wrap items-center gap-2">
|
|
<button type="button"
|
|
onClick={() => runAction(a)}
|
|
disabled={running !== null}
|
|
className={`inline-flex items-center gap-2 rounded-lg border px-3 py-1.5 text-sm font-semibold disabled:opacity-60 ${
|
|
a.danger ? 'border-danger text-danger' : 'border-border text-content'
|
|
}`}
|
|
>
|
|
{running === a.key && <Loader2 className="w-4 h-4 animate-spin" />}
|
|
{a.label}
|
|
</button>
|
|
{a.hint && <span className="text-xs text-content-muted">{a.hint}</span>}
|
|
{res && (
|
|
<span className={`text-xs font-medium ${res.ok ? 'text-success' : 'text-danger'}`}>
|
|
{res.message || (res.ok ? t('common.success') : t('common.error'))}
|
|
</span>
|
|
)}
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
</div>
|
|
)}
|
|
<PluginOAuthSection id={id} state={oauth} setState={setOauth} />
|
|
</div>
|
|
)
|
|
}
|
|
|
|
/**
|
|
* 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 (
|
|
<div className="rounded-xl border border-border bg-surface p-5">
|
|
<div className="flex items-center gap-2 mb-4">
|
|
<PluginIcon name={icon} className="w-4 h-4 text-content-secondary" />
|
|
<h3 className="text-sm font-semibold text-content">{name}</h3>
|
|
</div>
|
|
{/* 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. */}
|
|
<div className="min-h-[120px]">
|
|
<PluginFrame pluginId={id} path="settings.html" title={name} surface="user-settings" className="[color-scheme:light]" />
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export default function PluginSettingsTab() {
|
|
const { t } = useTranslation()
|
|
const plugins = usePluginStore(s => s.plugins)
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
<div>
|
|
<h2 className="text-lg font-semibold text-content">{t('settings.plugins.title')}</h2>
|
|
<p className="text-sm text-content-muted">{t('settings.plugins.subtitle')}</p>
|
|
</div>
|
|
{plugins.length === 0
|
|
? <p className="text-sm text-content-muted">{t('settings.plugins.empty')}</p>
|
|
: plugins.map(p => (
|
|
<div key={p.id} className="space-y-4">
|
|
<PluginSettingsForm id={p.id} name={p.name} icon={p.icon} />
|
|
{p.settingsUi && <PluginSettingsUiCard id={p.id} name={p.name} icon={p.icon} />}
|
|
</div>
|
|
))}
|
|
<PluginActivityPanel />
|
|
</div>
|
|
)
|
|
}
|