import { html, nothing } from 'lit'; import { LightElement } from '../lib/base.js'; import { announceChange, 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 everything else that is per-connector and was // scattered before: the Test button, the global enable, and the per-user access // grants — which used to be a second modal reached from a third place. // // 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 }, _users: { state: true }, // admin: for the access panel _access: { state: true }, // admin: Set of granted user ids _noIcon: { state: true }, _oauth: { state: true }, // in-flight OAuth login: { state, auth_url, code } }; } 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._users = null; this._access = null; this._oauth = null; } connectedCallback() { super.connectedCallback(); 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(); }); } get _isAdmin() { return this._me?.role_id === ADMIN_ID; } get _isGlobal() { return (this._entry?.scope ?? (this._glob ? 'global' : null)) === 'global'; } get _status() { return statusOf({ _act: this._act, _glob: this._glob }); } 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 = `No connector named “${this._name}” is available to you.`; 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 } }; if (this._isAdmin && this._isGlobal) await this._loadAccess(); } catch (e) { this._error = e.message; } } async _loadAccess() { try { this._users = await jf('/api/users'); if (this._glob) { const granted = await jf(`/api/mcp/global/${this._glob.id}/access`); this._access = new Set(granted || []); } } 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 = 'Saved, but the credentials did not check out — fix them and test again.'; } 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(`Deactivate “${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; } } 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 = 'Verification failed — the connector stays disabled until the credentials are fixed.'; } 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(`Disable “${this._glob.friendly_name || this._name}”?\n\nIt stops for everyone who can use it.`)) 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; } } _toggleAccess(userId) { const next = new Set(this._access); next.has(userId) ? next.delete(userId) : next.add(userId); this._access = next; } async _saveAccess() { this._busy = true; try { await jf(`/api/mcp/global/${this._glob.id}/access`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ user_ids: [...this._access] }), }); 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()}
Loading…
`; } return html`
${this._renderHeader()}
${this._error ? html`
${this._error}
` : nothing} ${this._renderSummary()} ${this._renderConfig()} ${this._renderAccess()}
`; } _renderHeader() { const title = this._entry?.friendly_name || this._glob?.friendly_name || this._name || 'Connector'; return html`

${title}

`; } _renderSummary() { const e = this._entry; const isScript = e?.source === 'local_script'; const status = this._status; const desc = e?.description || this._glob?.description; 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 ? 'global' : 'per-user'} ${isScript ? html` runs code on this box ` : nothing} ${e?.auth_kind && e.auth_kind !== 'none' ? html` ${e.auth_kind}` : nothing} ${status === 'active' ? html` active` : nothing} ${status === 'pending' ? html` needs fixing` : nothing} ${status === 'needs_login' ? html` needs sign-in` : nothing}
${this._isGlobal ? html`
Runs once for the household, on the host. Nobody reaches it until they are granted access.
` : 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`

This connector is managed for you.

It is enabled by an admin and granted to you — there is nothing to configure.

`; } 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`

Sign in

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

${active ? 'Configuration' : 'Set up'}

${active ? html`
${this._isGlobal ? 'Already enabled. Re-submitting replaces the stored credentials.' : 'Already active. Re-submitting replaces the stored credentials.'}
` : 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`
Signs in with ${label}. You approve access in a browser tab, then paste back the code the page shows you — nothing is stored on this box until you do.
${scopes.length ? html`
It will request access to:
` : nothing} ${active ? html`
Signed in and active.
` : nothing} ${!this._oauth ? html`
${this._act ? html` ` : nothing}
` : html`
A tab opened for ${label}. Approve access there.
Paste the code the page gave you:
{ this._oauth = { ...this._oauth, code: ev.target.value }; }} />
`} `; } _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 t = this._test; if (t === null) return nothing; if (t === 'running') { return html`
Testing credentials…
`; } if (t.skipped) { return html`
${t.message || 'No verification step for this connector.'}
`; } return html`
${t.ok ? 'OK' : 'Failed'} — ${t.message} ${t.details ? html`
${JSON.stringify(t.details, null, 2)}
` : nothing}
`; } /// Who may use this global connector. Only meaningful once it is enabled — there /// is no instance to grant access to before that. _renderAccess() { if (!this._isGlobal || !this._isAdmin || !this._glob) return nothing; const users = this._users ?? []; return html`

Who can use it

Ticking a box grants this connector's tools to that person's agent. Saving replaces the whole list.
${users.length === 0 ? html`

No users.

` : html`
${users.map(u => html`
this._toggleAccess(u.id)} />
`)}
`}
`; } }