import { html, nothing } from 'lit'; import { LightElement } from '../lib/base.js'; import { t } from '../lib/i18n.js'; import { announceChange, authLabel, connectorIconUrl, jf, normalizeSchema, parseJson, seedEnv, statusOf, } from './shared/connector-common.js'; // One connector's own page — `#connector?name=`. // // This replaces the activation dialog. A connector declares its own env/secret // schema, so the form's height is the *connector's* choice, not the UI's: EMAIL asks // for a dozen fields, and a fixed-size modal simply could not hold them — it grew // taller than the viewport and the buttons went off-screen. A page scrolls. // // It is also the natural home for the other per-connector actions: the Test button // and the global enable. Access grants are **not** here on purpose: they live only // on the Users page, so "who has what" has a single surface. // // Deliberately not a `name` field: the list is one row per connector (§7 template), // so the runtime name is the catalog name. The backend still defends against // collisions; the UI just stops offering a way to cause them. const ADMIN_ID = 'admin'; const PAGE_ID = 'connector'; function nameFromHash() { const m = location.hash.match(/^#connector\?name=(.*)$/); if (!m) return null; try { return decodeURIComponent(m[1]); } catch { return null; } } export class ConnectorDetailPage extends LightElement { static get properties() { return { _open: { state: true }, _name: { state: true }, _me: { state: true }, _entry: { state: true }, // catalog row (null for a global we cannot read) _act: { state: true }, // my activation row, if any _glob: { state: true }, // the global instance, if any _schema: { state: true }, _form: { state: true }, // { api_key, env: {} } _test: { state: true }, // null | 'running' | report _busy: { state: true }, _error: { state: true }, _noIcon: { state: true }, _oauth: { state: true }, // in-flight OAuth login: { state, auth_url, code } _qr: { state: true }, // in-flight QR/device login: { state, qr, message } }; } constructor() { super(); this._open = false; this._noIcon = false; this._reset(); } _reset() { this._name = null; this._me = null; this._entry = null; this._act = null; this._glob = null; this._schema = []; this._form = { api_key: '', env: {} }; this._test = null; this._busy = false; this._error = null; this._oauth = null; this._qr = null; this._qrServerId = null; this._stopQrPoll(); } 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(); else this._stopQrPoll(); // never poll a connector's login off-screen }); window.addEventListener('hashchange', () => { if (this._open) this._loadFromHash(); }); } disconnectedCallback() { window.removeEventListener('locale-changed', this.__onLocaleChanged); this._stopQrPoll(); super.disconnectedCallback(); } get _isAdmin() { return this._me?.role_id === ADMIN_ID; } get _isGlobal() { return (this._entry?.scope ?? (this._glob ? 'global' : null)) === 'global'; } get _status() { const s = statusOf({ _act: this._act, _glob: this._glob }); // A QR/device connector at `pending` is waiting for its scan, not misconfigured. if (s === 'pending' && this._entry?.auth_kind === 'qr') return 'needs_login'; return s; } async _loadFromHash() { const name = nameFromHash(); if (!name) return; // A different connector must not inherit the previous one's typed secrets. if (name !== this._name) this._reset(); this._name = name; await this._load(); } async _load() { this._error = null; try { this._me = await jf('/api/auth/me'); const [available, activated] = await Promise.all([ jf('/api/mcp/available'), jf('/api/mcp/activated'), ]); const entry = (available?.catalog ?? []).find(e => e.name === this._name) ?? null; const glob = (available?.globals ?? []) .find(g => (g.catalog_name ?? g.name) === this._name) ?? null; const act = (activated ?? []).find(r => r.catalog_name === this._name) ?? null; if (!entry && !glob) { this._error = t('connectors.error.no_connector', { name: this._name }); return; } this._entry = entry; this._glob = glob; this._act = act; const schema = normalizeSchema(parseJson(entry?.config_schema_json, [])); this._schema = schema; // Keep whatever the user has already typed across a reload triggered by a save. this._form = { api_key: this._form.api_key || '', env: { ...seedEnv(schema), ...this._form.env } }; } catch (e) { this._error = e.message; } } _back() { // Prefer real history so the browser's own Back stays consistent; fall back to // the list when this page was opened straight from a pasted URL. if (history.length > 1) { history.back(); return; } history.pushState({ page: 'connectors' }, '', '#connectors'); window.dispatchEvent(new CustomEvent('llm-page-change', { detail: { page: 'connectors' } })); } _patchEnv(key, value) { this._form = { ...this._form, env: { ...this._form.env, [key]: value } }; } /// The env map to send: empty fields are dropped so a blank box means "unset" /// rather than "set to empty string". get _envPayload() { const env = {}; for (const [k, v] of Object.entries(this._form.env || {})) if (v !== '') env[k] = v; return Object.keys(env).length ? env : null; } // ── Actions ──────────────────────────────────────────────────────────────── async _testCreds() { this._test = 'running'; try { this._test = await jf('/api/mcp/test', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ catalog_name: this._name, api_key: this._form.api_key || null, env: this._envPayload, }), }); } catch (e) { this._test = { ok: false, message: e.message }; } } async _activate() { this._busy = true; this._error = null; try { const res = await jf('/api/mcp/activate', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ catalog_name: this._name, api_key: this._form.api_key || null, env: this._envPayload, }), }); if (res?.auth_state === 'pending') { this._test = res.verify ?? { ok: false, message: 'Verification failed.' }; this._error = t('connectors.detail.test.error_saved'); } else if (res?.error) { this._error = res.error; } announceChange(); await this._load(); } catch (e) { this._error = e.message; } finally { this._busy = false; } } async _deactivate() { if (!confirm(t('connectors.detail.confirm.deactivate', { name: this._entry?.friendly_name || this._name }))) return; this._busy = true; try { await jf(`/api/mcp/activated/${this._act.id}`, { method: 'DELETE' }); this._act = null; this._test = null; announceChange(); await this._load(); } catch (e) { this._error = e.message; } finally { this._busy = false; } } // ── OAuth login (§15): activate → consent in a tab → paste code → complete ──── async _startOauth() { this._busy = true; this._error = null; try { // The activation may not exist yet (first sign-in) — create the pending row, // then reuse it. A `pending` row from a previous attempt is signed in again. let serverId = this._act?.id; if (!serverId) { const res = await jf('/api/mcp/activate', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ catalog_name: this._name }), }); if (res?.error) { this._error = res.error; return; } serverId = res.id; } const start = await jf('/api/mcp/oauth/start', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ server_id: serverId }), }); this._oauth = { state: start.state, auth_url: start.auth_url, code: '' }; window.open(start.auth_url, '_blank', 'noopener'); announceChange(); await this._load(); } catch (e) { this._error = e.message; } finally { this._busy = false; } } async _completeOauth() { this._busy = true; this._error = null; try { const res = await jf('/api/mcp/oauth/complete', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ state: this._oauth.state, code: this._oauth.code.trim() }), }); if (res?.error) { this._error = res.error; } else { this._oauth = null; } announceChange(); await this._load(); } catch (e) { this._error = e.message; } finally { this._busy = false; } } // ── QR / device login (§15): activate → server emits a QR → scan → poll ready ── // Unlike OAuth there is no code to paste: the connector's server must run to // produce the QR, so activation starts it and we poll `login_status` until the // phone scan flips it to `ready`. async _startQrLogin() { this._busy = true; this._error = null; try { // First sign-in creates the pending row (which installs deps + starts the // server — this can take a while on a cold container). Reuse it thereafter. let serverId = this._act?.id; if (!serverId) { const res = await jf('/api/mcp/activate', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ catalog_name: this._name }), }); if (res?.error) { this._error = res.error; return; } serverId = res.id; } this._qrServerId = serverId; await this._pollQr(); // fetch the first QR immediately this._startQrPoll(); // then keep it fresh await this._load(); } catch (e) { this._error = e.message; } finally { this._busy = false; } } _startQrPoll() { this._stopQrPoll(); // The QR rotates every ~20 s and the scan can land any moment: poll briskly. this.__qrTimer = setInterval(() => this._pollQr(), 2500); } _stopQrPoll() { if (this.__qrTimer) { clearInterval(this.__qrTimer); this.__qrTimer = null; } } async _pollQr() { if (!this._qrServerId) return; try { const res = await jf('/api/mcp/login/status', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ server_id: this._qrServerId }), }); this._qr = res; if (res?.state === 'ready') { this._stopQrPoll(); await this._load(); // pick up the flipped auth_state } } catch (_) { /* transient (server still connecting) — keep polling */ } } async _resetQrLogin() { const id = this._qrServerId || this._act?.id; if (!id) return; this._busy = true; this._error = null; try { await jf('/api/mcp/login/reset', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ server_id: id }), }); this._qr = null; this._qrServerId = id; await this._pollQr(); this._startQrPoll(); await this._load(); } catch (e) { this._error = e.message; } finally { this._busy = false; } } async _enableGlobal() { this._busy = true; this._error = null; try { const res = await jf('/api/mcp/global', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ catalog_name: this._name, api_key: this._form.api_key || null, env: this._envPayload, }), }); if (res?.verify && !res.verify.ok && !res.verify.skipped) { this._test = res.verify; this._error = t('connectors.detail.test.error_verify'); } else if (res?.error) { this._error = res.error; } announceChange(); await this._load(); } catch (e) { this._error = e.message; } finally { this._busy = false; } } async _disableGlobal() { if (!confirm(t('connectors.detail.confirm.disable_global', { name: this._glob.friendly_name || this._name }))) return; this._busy = true; try { await jf(`/api/mcp/global/${this._glob.id}`, { method: 'DELETE' }); this._glob = null; announceChange(); await this._load(); } catch (e) { this._error = e.message; } finally { this._busy = false; } } // ── Render ───────────────────────────────────────────────────────────────── render() { if (!this._open) return nothing; if (this._error && !this._entry && !this._glob) { return html`
${this._renderHeader()}
${this._error}
`; } if (!this._entry && !this._glob) { return html`
${this._renderHeader()}
${t('connectors.loading')}
`; } return html`
${this._renderHeader()}
${this._error ? html`
${this._error}
` : nothing} ${this._renderSummary()} ${this._renderConfig()}
`; } _renderHeader() { const title = this._entry?.friendly_name || this._glob?.friendly_name || this._name || 'Connector'; return html` `; } _renderSummary() { const e = this._entry; const isScript = e?.source === 'local_script'; const status = this._status; const desc = e?.description || this._glob?.description; const _statusText = (s) => ({ active: t('connectors.detail.status.active'), pending: t('connectors.detail.status.needs_fix'), needs_login: t('connectors.detail.status.needs_signin') })[s] ?? s; return html`
${!this._noIcon ? html` { this._noIcon = true; }} />` : html`
`}
${e?.friendly_name || this._glob?.friendly_name || this._name}
${this._name}
${desc ? html`
${desc}
` : nothing}
${this._isGlobal ? t('connectors.detail.detail_scope_global') : t('connectors.chip.per_user')} ${isScript ? html` ${t('connectors.detail.scope_local')} ` : nothing} ${e?.auth_kind && e.auth_kind !== 'none' ? html` ${authLabel(e.auth_kind)}` : nothing} ${status === 'active' ? html` ${t('connectors.detail.status.active')}` : nothing} ${status === 'pending' ? html` ${t('connectors.detail.status.needs_fix')}` : nothing} ${status === 'needs_login' ? html` ${t('connectors.detail.status.needs_signin')}` : nothing}
${this._isGlobal ? html`
${t('connectors.detail.global_note')}
` : nothing}
`; } _renderConfig() { const e = this._entry; // A granted global we have no catalog row for: nothing here is ours to configure. if (!e) { return html`

${t('connectors.detail.managed.title')}

${t('connectors.detail.managed.desc')}

`; } const active = this._isGlobal ? !!this._glob : !!this._act; const canManage = this._isGlobal ? this._isAdmin : true; const hasVerify = !!e.verify_command; const oauth = e.auth_kind === 'oauth'; // An api_key connector that declares its key as a described `env[]` field (secret) // collects it there — the generic, label-less "API key" box would be a duplicate // asking for the same value. Fall back to the generic box only when the schema // names no secret of its own (a bare `requires:[API_KEY]` connector). const schemaHasSecret = this._schema.some(f => f.secret); if (this._isGlobal && !this._isAdmin) return nothing; // OAuth is a per-user, interactive flow — a browser consent, not a form of // typed credentials — so it gets its own panel instead of the api_key/env body. if (oauth && !this._isGlobal) { return html`

${t('connectors.detail.oauth.title')}

${this._renderOauth()}
`; } // QR / device login (WhatsApp): the server produces a QR the user scans with // their phone — its own panel, like OAuth. if (e.auth_kind === 'qr' && !this._isGlobal) { return html`

${t('connectors.detail.qr.title')}

${this._renderQr()}
`; } return html`

${active ? t('connectors.detail.config.title_active') : t('connectors.detail.config.title_setup')}

${active ? html`
${this._isGlobal ? t('connectors.detail.config.already_global') : t('connectors.detail.config.already_user')}
` : nothing} ${e.auth_kind === 'api_key' && !schemaHasSecret ? html`
{ this._form = { ...this._form, api_key: ev.target.value }; }} />
` : nothing} ${this._renderEnvFields()} ${this._renderVerifyBox()}
${hasVerify && canManage ? html` ` : nothing} ${this._isGlobal ? html` ${this._glob ? html` ` : nothing}` : html` ${this._act ? html` ` : nothing}`}
`; } _renderOauth() { const provider = this._entry?.oauth_provider || 'provider'; const label = provider.charAt(0).toUpperCase() + provider.slice(1); const active = this._act && this._act.auth_state === 'ready'; const pending = this._act && this._act.auth_state === 'pending'; const scopes = parseJson(this._entry?.oauth_scopes_json, []); return html`
${t('connectors.detail.oauth.desc', { provider: label })}
${scopes.length ? html`
${t('connectors.detail.oauth.scopes')}
` : nothing} ${active ? html`
${t('connectors.detail.oauth.signed_in')}
` : nothing} ${!this._oauth ? html`
${this._act ? html` ` : nothing}
` : html`
${t('connectors.detail.oauth.step1', { provider: label })}
${t('connectors.detail.oauth.step2')}
{ this._oauth = { ...this._oauth, code: ev.target.value }; }} />
`} `; } _renderQr() { const active = this._act && this._act.auth_state === 'ready'; const q = this._qr; const st = q?.state; const polling = !!this.__qrTimer; return html`
${t('connectors.detail.qr.desc')}
${active && st !== 'need_scan' && st !== 'logged_out' ? html`
${t('connectors.detail.qr.connected')}
` : nothing} ${st === 'need_scan' && q?.qr ? html`
${t('connectors.detail.qr.scan')}
WhatsApp QR
${t('connectors.detail.qr.hint')}
` : nothing} ${polling && st && st !== 'ready' && st !== 'need_scan' ? html`
${q?.message || t('connectors.detail.qr.connecting')}
` : nothing}
${!active && !polling ? html` ` : nothing} ${active || polling ? html` ` : nothing} ${this._act ? html` ` : nothing}
`; } _renderEnvFields() { if (!this._schema.length) return nothing; return this._schema.map(f => html`
this._patchEnv(f.name, ev.target.value)} /> ${f.description ? html`
${f.description}
` : nothing}
`); } _renderVerifyBox() { const result = this._test; if (result === null) return nothing; if (result === 'running') { return html`
${t('connectors.detail.test.running')}
`; } if (result.skipped) { return html`
${result.message || t('connectors.detail.test.skipped')}
`; } return html`
${result.ok ? t('connectors.detail.test.ok_label') : t('connectors.detail.test.fail_label')} — ${result.message} ${t.details ? html`
${JSON.stringify(t.details, null, 2)}
` : nothing}
`; } }