import { html, nothing } from 'lit'; import { LightElement } from '../lib/base.js'; import { t } from '../lib/i18n.js'; import { jf, schemaFields, pluginHealth } from './shared/plugin-common.js'; // One plugin's admin page (`#plugin-detail?id=`), reached from the // Configure button on `#plugins` — the plugin counterpart of // `connector-detail.js`. // // Hosts what was squeezed into the old combined page: the instance-wide // config form (`config_schema`, saved via `PUT /api/plugins/{id}`) and the // enable toggle, repeated in the summary card so a full setup round-trip // happens on one page. // // Access used to be an editable checklist of every user here. It is now a // read-only roster (`GET /api/plugins/{id}/access`) linking to each person's // page: granting is done on the *user*, next to their connector grants, because // "what may this person use" is the question an admin actually asks — and // answering it plugin-by-plugin meant opening every plugin in turn. One write // path, so the two surfaces cannot disagree about who has what. const PAGE_ID = 'plugin-detail'; function idFromHash() { const m = location.hash.match(/^#plugin-detail\?id=(.*)$/); if (!m) return null; try { return decodeURIComponent(m[1]); } catch { return null; } } export class PluginDetailPage extends LightElement { static get properties() { return { _open: { state: true }, _id: { state: true }, _plugin: { state: true }, // PluginInfo _customPage: { state: true }, // this plugin's own admin page, or null _error: { state: true }, _draft: { state: true }, // config form draft _status: { state: true }, // { ok?: string, err?: string } _access: { state: true }, // AccessEntry[] — read-only roster _accessErr: { state: true }, }; } constructor() { super(); this._open = false; this._reset(); } _reset() { this._id = null; this._plugin = null; this._customPage = null; this._error = null; this._draft = null; this._status = {}; this._access = null; this._accessErr = null; } connectedCallback() { super.connectedCallback(); this.__onLocaleChanged = () => this.requestUpdate(); window.addEventListener('locale-changed', this.__onLocaleChanged); window.addEventListener('llm-page-change', (e) => { this._open = e.detail.page === PAGE_ID; this.style.display = this._open ? 'flex' : 'none'; if (this._open) this._loadFromHash(); }); window.addEventListener('hashchange', () => { if (this._open) this._loadFromHash(); }); } disconnectedCallback() { window.removeEventListener('locale-changed', this.__onLocaleChanged); super.disconnectedCallback(); } async _loadFromHash() { const id = idFromHash(); if (!id) return; // A different plugin must not inherit the previous one's typed config. if (id !== this._id) this._reset(); this._id = id; await this._load(); } async _load() { this._error = null; try { const all = await jf('/api/plugins'); const p = (all ?? []).find(x => x.id === this._id) ?? null; if (!p) { this._plugin = null; this._error = t('plugins.detail.not_found', { id: this._id }); return; } this._plugin = p; // If the plugin ships its own page(s), the generic config form may defer // to them — see `_renderConfig`. Prefer an `admin_only` console page; // otherwise any page of this plugin will do (e.g. mobile-connector's // Mobile App page, which hosts its own settings dialog). try { const pages = await jf('/api/plugins/pages'); const mine = (pages ?? []).filter(pg => pg.plugin_id === this._id); this._customPage = mine.find(pg => pg.admin_only) ?? mine[0] ?? null; } catch { this._customPage = null; } // Keep whatever the admin has already typed across a reload triggered by a save. this._draft = { ...(p.config || {}), ...(this._draft || {}) }; // Binding-managed plugins (e.g. mobile-connector) gate access through // their own pairing lifecycle — there is no grant roster to show. if (!p.manages_own_access) await this._loadAccess(); } catch (e) { this._error = e.message; } } async _loadAccess() { try { this._access = await jf(`/api/plugins/${encodeURIComponent(this._id)}/access`); } catch (e) { this._accessErr = e.message; } } _back() { // Prefer real history so the browser's own Back stays consistent; fall back // to the plugins list when this page was opened straight from a pasted URL. if (history.length > 1) { history.back(); return; } history.pushState({ page: 'plugins' }, '', '#plugins'); window.dispatchEvent(new CustomEvent('llm-page-change', { detail: { page: 'plugins' } })); } _setDraft(key, value) { this._draft = { ...this._draft, [key]: value }; } async _save(enabled) { this._status = {}; const fields = schemaFields(this._plugin.config_schema); for (const f of fields) { if (f.required && !this._draft[f.key]) { this._status = { err: t('plugins.error.required', { field: f.label }) }; return; } } try { await jf(`/api/plugins/${encodeURIComponent(this._id)}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ enabled, config: this._draft || {} }), }); this._status = { ok: t('plugins.saved') }; this._draft = null; // re-seed from the persisted config await this._load(); window.dispatchEvent(new CustomEvent('plugins-changed')); } catch (e) { this._status = { err: e.message }; } } // Opens a user's page — the surface that owns the grant. `#users/{id}` is the // same route the Users list pushes, so Back behaves identically. _openUser(e, userId) { e.preventDefault(); const hash = userId ? `#users/${encodeURIComponent(userId)}` : '#users'; history.pushState({ page: 'users', user: userId ?? undefined }, '', hash); window.dispatchEvent(new CustomEvent('llm-page-change', { detail: { page: 'users' } })); } // ── Render ───────────────────────────────────────────────────────────────── render() { if (!this._open) return nothing; if (this._error && !this._plugin) { return html`
${this._renderHeader()}
${this._error}
`; } if (!this._plugin) { return html`
${this._renderHeader()}
${t('plugins.loading')}
`; } return html`
${this._renderHeader()}
${this._renderSummary()} ${this._renderConfig()} ${this._plugin.manages_own_access ? nothing : this._renderAccess()}
`; } _renderHeader() { return html`

${this._plugin?.name || this._id || 'Plugin'}

`; } _renderSummary() { const p = this._plugin; const h = pluginHealth(p); const cls = h === 'ok' ? 'ok' : (h === 'off' ? 'off' : 'err'); return html`
${p.name}
${p.id}
${t(`plugins.health.${h}`)}
${p.description ? html`
${p.description}
` : nothing}
${p.has_user_page ? html` ${t('plugins.badge.user_page')}` : nothing}
this._save(e.target.checked)} />
`; } _openCustomPage(e, route) { e.preventDefault(); history.pushState({ page: route }, '', '#' + route); window.dispatchEvent(new CustomEvent('llm-page-change', { detail: { page: route } })); } _renderConfigLink() { const cp = this._customPage; const route = `plugin/${cp.plugin_id}/${cp.page_id}`; return html`

${t('plugins.detail.config.title')}

${t('plugins.detail.config.custom_page')}
this._openCustomPage(e, route)}> ${t('plugins.detail.config.open')}
`; } _renderConfig() { const p = this._plugin; const fields = schemaFields(p.config_schema); // The plugin hosts its own config UI in one of its pages (e.g. the mobile // connector's settings dialog): link out instead of duplicating the form. if (p.config_in_detail_page === false) { return this._customPage ? this._renderConfigLink() : nothing; } // Defer to the plugin's own admin page only when there is no generic // instance-config to show. if (fields.length === 0 && this._customPage) return this._renderConfigLink(); const draft = this._draft || {}; return html`

${t('plugins.detail.config.title')}

${fields.length === 0 ? html`
${t('plugins.detail.config.empty')}
` : html` ${fields.map(f => html`
${f.type === 'boolean' ? html`
this._setDraft(f.key, e.target.checked)} />
` : html` this._setDraft(f.key, f.type === 'number' ? Number(e.target.value) : e.target.value)} />`} ${f.description ? html`
${f.description}
` : nothing}
`)} ${this._status.err ? html`
${this._status.err}
` : nothing} ${this._status.ok ? html`
${this._status.ok}
` : nothing} `}
`; } _renderAccess() { const granted = (this._access ?? []).filter(u => u.granted); return html`

${t('plugins.detail.access.title')}

${t('plugins.access.desc')}
${this._accessErr ? html`
${this._accessErr}
` : nothing} ${this._access === null ? html`
` : html` ${granted.length === 0 ? html`

${t('plugins.access.nobody')}

` : html`
${granted.map(u => html` `)}
`} this._openUser(e, null)}> ${t('plugins.access.manage')} `}
`; } }