Changeset 0.38.1 (#234)
Co-authored-by: gobha <jasafpro@gmail.com> Co-committed-by: gobha <jasafpro@gmail.com>
This commit is contained in:
@@ -188,6 +188,12 @@ export function createDomains(restClient) {
|
||||
fetchModels: (id) => rc.post(`/api/v1/api-configs/${id}/models/fetch`),
|
||||
},
|
||||
|
||||
// ── 10b. Connections (v0.38.1) ────────
|
||||
connections: {
|
||||
...crud(rc, '/api/v1/connections'),
|
||||
resolve: (type, name) => rc.get('/api/v1/connections/resolve' + _qs({ type, name })),
|
||||
},
|
||||
|
||||
// ── 11. Notifications ──────────────────
|
||||
notifications: {
|
||||
list: (opts) => rc.get('/api/v1/notifications' + _qs(opts)),
|
||||
@@ -241,6 +247,11 @@ export function createDomains(restClient) {
|
||||
updateProvider:(id, pid, data) => rc.put(`/api/v1/teams/${id}/providers/${pid}`, data),
|
||||
deleteProvider:(id, pid) => rc.del(`/api/v1/teams/${id}/providers/${pid}`),
|
||||
providerModels:(id, pid) => rc.get(`/api/v1/teams/${id}/providers/${pid}/models`),
|
||||
// Team connections (v0.38.1)
|
||||
connections: (id) => rc.get(`/api/v1/teams/${id}/connections`),
|
||||
createConnection: (id, data) => rc.post(`/api/v1/teams/${id}/connections`, data),
|
||||
updateConnection: (id, cid, data) => rc.put(`/api/v1/teams/${id}/connections/${cid}`, data),
|
||||
deleteConnection: (id, cid) => rc.del(`/api/v1/teams/${id}/connections/${cid}`),
|
||||
roles: (id) => rc.get(`/api/v1/teams/${id}/roles`),
|
||||
updateRole: (id, role, config) => rc.put(`/api/v1/teams/${id}/roles/${role}`, config),
|
||||
deleteRole: (id, role) => rc.del(`/api/v1/teams/${id}/roles/${role}`),
|
||||
@@ -477,6 +488,9 @@ export function createDomains(restClient) {
|
||||
del: (id) => rc.del(`/api/v1/admin/extensions/${id}`),
|
||||
},
|
||||
|
||||
// v0.38.1: Global connections
|
||||
connections: crud(rc, '/api/v1/admin/connections'),
|
||||
|
||||
packages: {
|
||||
list: (opts) => rc.get('/api/v1/admin/packages' + _qs(opts)),
|
||||
get: (id) => rc.get(`/api/v1/admin/packages/${id}`),
|
||||
|
||||
@@ -152,7 +152,7 @@ export async function boot() {
|
||||
};
|
||||
|
||||
// Marker for idempotency
|
||||
sw._sdk = '0.38.0';
|
||||
sw._sdk = '0.38.1';
|
||||
|
||||
// 8. Expose globally
|
||||
window.sw = sw;
|
||||
|
||||
219
src/js/sw/surfaces/admin/connections.js
Normal file
219
src/js/sw/surfaces/admin/connections.js
Normal file
@@ -0,0 +1,219 @@
|
||||
/**
|
||||
* Admin > Connections — global extension connection CRUD
|
||||
* v0.38.1: Scoped credential management for extensions
|
||||
*/
|
||||
const { html } = window;
|
||||
const { useState, useEffect, useCallback } = hooks;
|
||||
|
||||
function esc(s) { return s == null ? '' : String(s); }
|
||||
|
||||
export default function ConnectionsSection() {
|
||||
const [connections, setConnections] = useState(null);
|
||||
const [connTypes, setConnTypes] = useState([]);
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [editId, setEditId] = useState(null);
|
||||
const [form, setForm] = useState({ type: '', package_id: '', name: '', config: {} });
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const resp = await sw.api.admin.connections.list();
|
||||
setConnections(resp || []);
|
||||
} catch (e) {
|
||||
setConnections([]);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const loadTypes = useCallback(async () => {
|
||||
try {
|
||||
const pkgs = await sw.api.admin.packages.list();
|
||||
const types = [];
|
||||
const seen = {};
|
||||
for (const pkg of (pkgs || [])) {
|
||||
const conns = pkg.manifest?.connections || [];
|
||||
for (const cd of conns) {
|
||||
if (!seen[cd.type]) {
|
||||
seen[cd.type] = true;
|
||||
types.push({ type: cd.type, label: cd.label || cd.type, packageId: pkg.id, fields: cd.fields || {} });
|
||||
}
|
||||
}
|
||||
}
|
||||
setConnTypes(types);
|
||||
} catch (_) {}
|
||||
}, []);
|
||||
|
||||
useEffect(() => { load(); loadTypes(); }, [load, loadTypes]);
|
||||
|
||||
const selectedType = connTypes.find(t => t.type === form.type);
|
||||
|
||||
const openAdd = useCallback(() => {
|
||||
setEditId(null);
|
||||
setForm({ type: connTypes[0]?.type || '', package_id: connTypes[0]?.packageId || '', name: '', config: {} });
|
||||
setShowForm(true);
|
||||
}, [connTypes]);
|
||||
|
||||
const openEdit = useCallback((conn) => {
|
||||
setEditId(conn.id);
|
||||
setForm({ type: conn.type, package_id: conn.package_id, name: conn.name, config: {} });
|
||||
setShowForm(true);
|
||||
}, []);
|
||||
|
||||
const cancel = useCallback(() => {
|
||||
setShowForm(false);
|
||||
setEditId(null);
|
||||
}, []);
|
||||
|
||||
const save = useCallback(async () => {
|
||||
if (!form.type || !form.name) {
|
||||
sw.emit('toast', { message: 'Type and name are required', variant: 'error' });
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
try {
|
||||
if (editId) {
|
||||
const patch = { name: form.name };
|
||||
if (Object.keys(form.config).length > 0) patch.config = form.config;
|
||||
await sw.api.admin.connections.update(editId, patch);
|
||||
sw.emit('toast', { message: 'Connection updated', variant: 'success' });
|
||||
} else {
|
||||
await sw.api.admin.connections.create({
|
||||
type: form.type, package_id: form.package_id,
|
||||
name: form.name, config: form.config,
|
||||
});
|
||||
sw.emit('toast', { message: 'Connection created', variant: 'success' });
|
||||
}
|
||||
setShowForm(false);
|
||||
setEditId(null);
|
||||
load();
|
||||
} catch (e) {
|
||||
sw.emit('toast', { message: e.message, variant: 'error' });
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, [form, editId, load]);
|
||||
|
||||
const del = useCallback(async (conn) => {
|
||||
const ok = await sw.confirm(`Remove connection "${conn.name}"?`);
|
||||
if (!ok) return;
|
||||
try {
|
||||
await sw.api.admin.connections.del(conn.id);
|
||||
sw.emit('toast', { message: 'Connection removed', variant: 'success' });
|
||||
load();
|
||||
} catch (e) {
|
||||
sw.emit('toast', { message: e.message, variant: 'error' });
|
||||
}
|
||||
}, [load]);
|
||||
|
||||
const setField = useCallback((key, val) => {
|
||||
setForm(f => ({ ...f, [key]: val }));
|
||||
}, []);
|
||||
|
||||
const setConfigField = useCallback((key, val) => {
|
||||
setForm(f => ({ ...f, config: { ...f.config, [key]: val } }));
|
||||
}, []);
|
||||
|
||||
const onTypeChange = useCallback((typeVal) => {
|
||||
const ct = connTypes.find(t => t.type === typeVal);
|
||||
setForm(f => ({ ...f, type: typeVal, package_id: ct?.packageId || f.package_id, config: {} }));
|
||||
}, [connTypes]);
|
||||
|
||||
if (connections === null) {
|
||||
return html`<div class="admin-placeholder">Loading connections\u2026</div>`;
|
||||
}
|
||||
|
||||
return html`
|
||||
<div style="margin-bottom:12px;display:flex;align-items:center;gap:8px;">
|
||||
<button class="btn-md btn-primary" onClick=${openAdd}
|
||||
disabled=${!connTypes.length}>+ Add Connection</button>
|
||||
${!connTypes.length && html`
|
||||
<span style="color:var(--text-3);font-size:12px;">
|
||||
No installed packages declare connection types.
|
||||
</span>
|
||||
`}
|
||||
</div>
|
||||
|
||||
${showForm && html`
|
||||
<div class="admin-card" style="margin-bottom:16px;padding:16px;">
|
||||
<h3 style="margin-top:0;">${editId ? 'Edit Connection' : 'Add Connection'}</h3>
|
||||
${!editId && html`
|
||||
<div class="form-group">
|
||||
<label>Connection Type</label>
|
||||
<select value=${form.type}
|
||||
onChange=${e => onTypeChange(e.target.value)}>
|
||||
${connTypes.map(ct => html`
|
||||
<option value=${ct.type}>${esc(ct.label)}</option>
|
||||
`)}
|
||||
</select>
|
||||
</div>
|
||||
`}
|
||||
<div class="form-group">
|
||||
<label>Name</label>
|
||||
<input type="text" placeholder="e.g. Production Gitea"
|
||||
value=${form.name}
|
||||
onInput=${e => setField('name', e.target.value)} />
|
||||
</div>
|
||||
${selectedType && Object.entries(selectedType.fields || {}).map(([fk, fd]) => html`
|
||||
<div class="form-group" key=${fk}>
|
||||
<label>${esc(fd.label || fk)}${fd.required ? ' *' : ''}${editId && fd.type === 'secret' ? ' (leave blank to keep)' : ''}</label>
|
||||
${fd.type === 'secret' ? html`
|
||||
<input type="password" placeholder="\u2022\u2022\u2022\u2022"
|
||||
value=${form.config[fk] || ''}
|
||||
onInput=${e => setConfigField(fk, e.target.value)} />
|
||||
` : fd.type === 'url' ? html`
|
||||
<input type="url" placeholder="https://..."
|
||||
value=${form.config[fk] || ''}
|
||||
onInput=${e => setConfigField(fk, e.target.value)} />
|
||||
` : fd.type === 'boolean' ? html`
|
||||
<input type="checkbox" checked=${!!form.config[fk]}
|
||||
onChange=${e => setConfigField(fk, e.target.checked)} />
|
||||
` : html`
|
||||
<input type="text"
|
||||
value=${form.config[fk] || ''}
|
||||
onInput=${e => setConfigField(fk, e.target.value)} />
|
||||
`}
|
||||
</div>
|
||||
`)}
|
||||
<div style="display:flex;gap:8px;margin-top:12px;">
|
||||
<button class="btn-md btn-primary" disabled=${saving}
|
||||
onClick=${save}>
|
||||
${saving ? 'Saving\u2026' : editId ? 'Update' : 'Add'}
|
||||
</button>
|
||||
<button class="btn-md btn-secondary" onClick=${cancel}>Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
`}
|
||||
|
||||
${!connections.length && !showForm && html`
|
||||
<div class="empty-hint">No global connections configured.</div>
|
||||
`}
|
||||
|
||||
${connections.length > 0 && html`
|
||||
<table class="admin-table">
|
||||
<thead><tr><th>Name</th><th>Type</th><th>Package</th><th>Status</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
${connections.map(c => html`
|
||||
<tr>
|
||||
<td style="font-weight:500;font-size:13px;">${esc(c.name)}</td>
|
||||
<td style="font-size:12px;color:var(--text-2);">${esc(c.type)}</td>
|
||||
<td style="font-size:12px;color:var(--text-3);">${esc(c.package_id)}</td>
|
||||
<td style="font-size:12px;">
|
||||
${c.is_active
|
||||
? html`<span style="color:var(--green);">\u25CF Active</span>`
|
||||
: html`<span style="color:var(--text-3);">\u25CB Inactive</span>`}
|
||||
</td>
|
||||
<td class="admin-actions-cell" style="white-space:nowrap;">
|
||||
<button class="icon-btn" title="Edit" onClick=${() => openEdit(c)}>
|
||||
\u{270F}\u{FE0F}
|
||||
</button>
|
||||
<button class="icon-btn icon-btn-danger" title="Delete"
|
||||
onClick=${() => del(c)}>
|
||||
\u{1F5D1}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
`)}
|
||||
</tbody>
|
||||
</table>
|
||||
`}
|
||||
`;
|
||||
}
|
||||
@@ -21,7 +21,7 @@ const ADMIN_SECTIONS = {
|
||||
ai: ['providers', 'models', 'personas', 'roles', 'knowledgeBases', 'memory'],
|
||||
workflows: ['workflows', 'tasks'],
|
||||
routing: ['health', 'routing', 'capabilities'],
|
||||
system: ['settings', 'storage', 'packages', 'channels', 'broadcast'],
|
||||
system: ['settings', 'storage', 'packages', 'connections', 'channels', 'broadcast'],
|
||||
monitoring: ['dashboard', 'usage', 'audit', 'stats'],
|
||||
};
|
||||
|
||||
@@ -32,6 +32,7 @@ const ADMIN_LABELS = {
|
||||
health: 'Health', routing: 'Routing', capabilities: 'Capabilities',
|
||||
workflows: 'Workflows', tasks: 'Tasks',
|
||||
settings: 'Settings', storage: 'Storage', packages: 'Packages',
|
||||
connections: 'Connections',
|
||||
channels: 'Channels', broadcast: 'Broadcast',
|
||||
dashboard: 'Dashboard', usage: 'Usage', audit: 'Audit', stats: 'Stats',
|
||||
};
|
||||
@@ -66,6 +67,7 @@ const sectionModules = {
|
||||
settings: () => import(`./settings.js${_v}`),
|
||||
storage: () => import(`./storage.js${_v}`),
|
||||
packages: () => import(`./packages.js${_v}`),
|
||||
connections: () => import(`./connections.js${_v}`),
|
||||
channels: () => import(`./channels.js${_v}`),
|
||||
broadcast: () => import(`./broadcast.js${_v}`),
|
||||
dashboard: () => import(`./dashboard.js${_v}`),
|
||||
|
||||
222
src/js/sw/surfaces/settings/connections.js
Normal file
222
src/js/sw/surfaces/settings/connections.js
Normal file
@@ -0,0 +1,222 @@
|
||||
/**
|
||||
* ConnectionsSection — personal extension connection CRUD
|
||||
* v0.38.1: Scoped credential management for extensions
|
||||
*/
|
||||
const { html } = window;
|
||||
const { useState, useEffect, useCallback } = hooks;
|
||||
|
||||
function esc(s) { return s == null ? '' : String(s); }
|
||||
|
||||
export function ConnectionsSection() {
|
||||
const [connections, setConnections] = useState(null);
|
||||
const [connTypes, setConnTypes] = useState([]);
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [editId, setEditId] = useState(null);
|
||||
const [form, setForm] = useState({ type: '', package_id: '', name: '', config: {} });
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const resp = await sw.api.connections.list();
|
||||
setConnections(resp || []);
|
||||
} catch (e) {
|
||||
setConnections([]);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const loadTypes = useCallback(async () => {
|
||||
try {
|
||||
const pkgs = await sw.api.admin.packages.list();
|
||||
const types = [];
|
||||
const seen = {};
|
||||
for (const pkg of (pkgs || [])) {
|
||||
const conns = pkg.manifest?.connections || [];
|
||||
for (const cd of conns) {
|
||||
if (!seen[cd.type]) {
|
||||
seen[cd.type] = true;
|
||||
types.push({ type: cd.type, label: cd.label || cd.type, packageId: pkg.id, fields: cd.fields || {} });
|
||||
}
|
||||
}
|
||||
}
|
||||
setConnTypes(types);
|
||||
} catch (_) {}
|
||||
}, []);
|
||||
|
||||
useEffect(() => { load(); loadTypes(); }, [load, loadTypes]);
|
||||
|
||||
const selectedType = connTypes.find(t => t.type === form.type);
|
||||
|
||||
const openAdd = useCallback(() => {
|
||||
setEditId(null);
|
||||
setForm({ type: connTypes[0]?.type || '', package_id: connTypes[0]?.packageId || '', name: '', config: {} });
|
||||
setShowForm(true);
|
||||
}, [connTypes]);
|
||||
|
||||
const openEdit = useCallback((conn) => {
|
||||
setEditId(conn.id);
|
||||
setForm({ type: conn.type, package_id: conn.package_id, name: conn.name, config: {} });
|
||||
setShowForm(true);
|
||||
}, []);
|
||||
|
||||
const cancel = useCallback(() => {
|
||||
setShowForm(false);
|
||||
setEditId(null);
|
||||
}, []);
|
||||
|
||||
const save = useCallback(async () => {
|
||||
if (!form.type || !form.name) {
|
||||
sw.emit('toast', { message: 'Type and name are required', variant: 'error' });
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
try {
|
||||
if (editId) {
|
||||
const patch = { name: form.name };
|
||||
if (Object.keys(form.config).length > 0) patch.config = form.config;
|
||||
await sw.api.connections.update(editId, patch);
|
||||
sw.emit('toast', { message: 'Connection updated', variant: 'success' });
|
||||
} else {
|
||||
await sw.api.connections.create({
|
||||
type: form.type, package_id: form.package_id,
|
||||
name: form.name, config: form.config,
|
||||
});
|
||||
sw.emit('toast', { message: 'Connection created', variant: 'success' });
|
||||
}
|
||||
setShowForm(false);
|
||||
setEditId(null);
|
||||
load();
|
||||
} catch (e) {
|
||||
sw.emit('toast', { message: e.message, variant: 'error' });
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, [form, editId, load]);
|
||||
|
||||
const del = useCallback(async (conn) => {
|
||||
const ok = await sw.confirm(`Remove connection "${conn.name}"?`);
|
||||
if (!ok) return;
|
||||
try {
|
||||
await sw.api.connections.del(conn.id);
|
||||
sw.emit('toast', { message: 'Connection removed', variant: 'success' });
|
||||
load();
|
||||
} catch (e) {
|
||||
sw.emit('toast', { message: e.message, variant: 'error' });
|
||||
}
|
||||
}, [load]);
|
||||
|
||||
const setField = useCallback((key, val) => {
|
||||
setForm(f => ({ ...f, [key]: val }));
|
||||
}, []);
|
||||
|
||||
const setConfigField = useCallback((key, val) => {
|
||||
setForm(f => ({ ...f, config: { ...f.config, [key]: val } }));
|
||||
}, []);
|
||||
|
||||
const onTypeChange = useCallback((typeVal) => {
|
||||
const ct = connTypes.find(t => t.type === typeVal);
|
||||
setForm(f => ({ ...f, type: typeVal, package_id: ct?.packageId || f.package_id, config: {} }));
|
||||
}, [connTypes]);
|
||||
|
||||
if (connections === null) {
|
||||
return html`<div class="settings-placeholder">Loading connections\u2026</div>`;
|
||||
}
|
||||
|
||||
return html`
|
||||
<div style="margin-bottom:12px;">
|
||||
<button class="btn-md btn-primary" onClick=${openAdd}
|
||||
disabled=${!connTypes.length}>+ Add Connection</button>
|
||||
${!connTypes.length && html`
|
||||
<span style="margin-left:8px;color:var(--text-3);font-size:12px;">
|
||||
No installed packages declare connection types.
|
||||
</span>
|
||||
`}
|
||||
</div>
|
||||
|
||||
${showForm && html`
|
||||
<div class="settings-section" style="margin-bottom:16px;">
|
||||
<h3>${editId ? 'Edit Connection' : 'Add Connection'}</h3>
|
||||
${!editId && html`
|
||||
<div class="form-group">
|
||||
<label>Connection Type</label>
|
||||
<select value=${form.type}
|
||||
onChange=${e => onTypeChange(e.target.value)}>
|
||||
${connTypes.map(ct => html`
|
||||
<option value=${ct.type}>${esc(ct.label)}</option>
|
||||
`)}
|
||||
</select>
|
||||
</div>
|
||||
`}
|
||||
<div class="form-group">
|
||||
<label>Name</label>
|
||||
<input type="text" placeholder="e.g. Work Gitea"
|
||||
value=${form.name}
|
||||
onInput=${e => setField('name', e.target.value)} />
|
||||
</div>
|
||||
${selectedType && Object.entries(selectedType.fields || {}).map(([fk, fd]) => html`
|
||||
<div class="form-group" key=${fk}>
|
||||
<label>${esc(fd.label || fk)}${fd.required ? ' *' : ''}${editId && fd.type === 'secret' ? ' (leave blank to keep current)' : ''}</label>
|
||||
${fd.type === 'secret' ? html`
|
||||
<input type="password" placeholder="\u2022\u2022\u2022\u2022"
|
||||
value=${form.config[fk] || ''}
|
||||
onInput=${e => setConfigField(fk, e.target.value)} />
|
||||
` : fd.type === 'url' ? html`
|
||||
<input type="url" placeholder="https://..."
|
||||
value=${form.config[fk] || ''}
|
||||
onInput=${e => setConfigField(fk, e.target.value)} />
|
||||
` : fd.type === 'boolean' ? html`
|
||||
<input type="checkbox" checked=${!!form.config[fk]}
|
||||
onChange=${e => setConfigField(fk, e.target.checked)} />
|
||||
` : fd.type === 'number' ? html`
|
||||
<input type="number"
|
||||
value=${form.config[fk] || ''}
|
||||
onInput=${e => setConfigField(fk, e.target.value)} />
|
||||
` : html`
|
||||
<input type="text"
|
||||
value=${form.config[fk] || ''}
|
||||
onInput=${e => setConfigField(fk, e.target.value)} />
|
||||
`}
|
||||
</div>
|
||||
`)}
|
||||
<div style="display:flex;gap:8px;margin-top:12px;">
|
||||
<button class="btn-md btn-primary" disabled=${saving}
|
||||
onClick=${save}>
|
||||
${saving ? 'Saving\u2026' : editId ? 'Update' : 'Add'}
|
||||
</button>
|
||||
<button class="btn-md btn-secondary" onClick=${cancel}>Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
`}
|
||||
|
||||
${!connections.length && !showForm && html`
|
||||
<div class="empty-hint">No connections configured.</div>
|
||||
`}
|
||||
|
||||
${connections.length > 0 && html`
|
||||
<table class="admin-table">
|
||||
<thead><tr><th>Name</th><th>Type</th><th>Status</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
${connections.map(c => html`
|
||||
<tr>
|
||||
<td style="font-weight:500;font-size:13px;">${esc(c.name)}</td>
|
||||
<td style="font-size:12px;color:var(--text-2);">${esc(c.type)}</td>
|
||||
<td style="font-size:12px;">
|
||||
${c.is_active
|
||||
? html`<span style="color:var(--green);">\u25CF Active</span>`
|
||||
: html`<span style="color:var(--text-3);">\u25CB Inactive</span>`}
|
||||
</td>
|
||||
<td class="admin-actions-cell" style="white-space:nowrap;">
|
||||
<button class="icon-btn" title="Edit" onClick=${() => openEdit(c)}>
|
||||
\u{270F}\u{FE0F}
|
||||
</button>
|
||||
<button class="icon-btn icon-btn-danger" title="Delete"
|
||||
onClick=${() => del(c)}>
|
||||
\u{1F5D1}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
`)}
|
||||
</tbody>
|
||||
</table>
|
||||
`}
|
||||
`;
|
||||
}
|
||||
@@ -27,6 +27,7 @@ const sectionModules = {
|
||||
teams: () => import('./teams.js'),
|
||||
models: () => import('./models.js'),
|
||||
providers: () => import('./providers.js'),
|
||||
connections: () => import('./connections.js'),
|
||||
personas: () => import('./personas.js'),
|
||||
roles: () => import('./roles.js'),
|
||||
usage: () => import('./usage.js'),
|
||||
@@ -49,6 +50,7 @@ const NAV_ITEMS = [
|
||||
{ key: 'teams', label: 'Teams' },
|
||||
{ key: 'workflows', label: 'Assignments' },
|
||||
{ key: 'tasks', label: 'Tasks' },
|
||||
{ key: 'connections', label: 'Connections' },
|
||||
{ key: 'gitkeys', label: 'Git Keys' },
|
||||
{ key: 'knowledge', label: 'Knowledge Bases' },
|
||||
{ key: 'memory', label: 'Memory' },
|
||||
@@ -67,6 +69,7 @@ const SECTION_TITLES = {
|
||||
general: 'General', appearance: 'Appearance', models: 'Models',
|
||||
personas: 'Personas', profile: 'Profile', teams: 'Teams',
|
||||
workflows: 'Assignments', tasks: 'Tasks', gitkeys: 'Git Keys',
|
||||
connections: 'Connections',
|
||||
data: 'Data & Privacy', providers: 'My Providers', roles: 'Model Roles',
|
||||
usage: 'My Usage', knowledge: 'Knowledge Bases', memory: 'Memory',
|
||||
notifications: 'Notifications',
|
||||
|
||||
210
src/js/sw/surfaces/team-admin/connections.js
Normal file
210
src/js/sw/surfaces/team-admin/connections.js
Normal file
@@ -0,0 +1,210 @@
|
||||
/**
|
||||
* Team Admin > Connections — team-scoped extension connection CRUD
|
||||
* v0.38.1: Scoped credential management for extensions
|
||||
*/
|
||||
const { html } = window;
|
||||
const { useState, useEffect, useCallback } = hooks;
|
||||
|
||||
function esc(s) { return s == null ? '' : String(s); }
|
||||
|
||||
export default function ConnectionsSection({ teamId }) {
|
||||
const [connections, setConnections] = useState(null);
|
||||
const [connTypes, setConnTypes] = useState([]);
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [editId, setEditId] = useState(null);
|
||||
const [form, setForm] = useState({ type: '', package_id: '', name: '', config: {} });
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const resp = await sw.api.teams.connections(teamId);
|
||||
setConnections(resp || []);
|
||||
} catch (e) {
|
||||
setConnections([]);
|
||||
}
|
||||
}, [teamId]);
|
||||
|
||||
const loadTypes = useCallback(async () => {
|
||||
try {
|
||||
const pkgs = await sw.api.admin.packages.list();
|
||||
const types = [];
|
||||
const seen = {};
|
||||
for (const pkg of (pkgs || [])) {
|
||||
const conns = pkg.manifest?.connections || [];
|
||||
for (const cd of conns) {
|
||||
if (!seen[cd.type]) {
|
||||
seen[cd.type] = true;
|
||||
types.push({ type: cd.type, label: cd.label || cd.type, packageId: pkg.id, fields: cd.fields || {} });
|
||||
}
|
||||
}
|
||||
}
|
||||
setConnTypes(types);
|
||||
} catch (_) {}
|
||||
}, []);
|
||||
|
||||
useEffect(() => { load(); loadTypes(); }, [load, loadTypes]);
|
||||
|
||||
const selectedType = connTypes.find(t => t.type === form.type);
|
||||
|
||||
const openAdd = useCallback(() => {
|
||||
setEditId(null);
|
||||
setForm({ type: connTypes[0]?.type || '', package_id: connTypes[0]?.packageId || '', name: '', config: {} });
|
||||
setShowForm(true);
|
||||
}, [connTypes]);
|
||||
|
||||
const openEdit = useCallback((conn) => {
|
||||
setEditId(conn.id);
|
||||
setForm({ type: conn.type, package_id: conn.package_id, name: conn.name, config: {} });
|
||||
setShowForm(true);
|
||||
}, []);
|
||||
|
||||
const cancel = useCallback(() => {
|
||||
setShowForm(false);
|
||||
setEditId(null);
|
||||
}, []);
|
||||
|
||||
const save = useCallback(async () => {
|
||||
if (!form.type || !form.name) {
|
||||
sw.emit('toast', { message: 'Type and name are required', variant: 'error' });
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
try {
|
||||
if (editId) {
|
||||
const patch = { name: form.name };
|
||||
if (Object.keys(form.config).length > 0) patch.config = form.config;
|
||||
await sw.api.teams.updateConnection(teamId, editId, patch);
|
||||
sw.emit('toast', { message: 'Connection updated', variant: 'success' });
|
||||
} else {
|
||||
await sw.api.teams.createConnection(teamId, {
|
||||
type: form.type, package_id: form.package_id,
|
||||
name: form.name, config: form.config,
|
||||
});
|
||||
sw.emit('toast', { message: 'Connection created', variant: 'success' });
|
||||
}
|
||||
setShowForm(false);
|
||||
setEditId(null);
|
||||
load();
|
||||
} catch (e) {
|
||||
sw.emit('toast', { message: e.message, variant: 'error' });
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, [form, editId, teamId, load]);
|
||||
|
||||
const del = useCallback(async (conn) => {
|
||||
const ok = await sw.confirm(`Remove connection "${conn.name}"?`);
|
||||
if (!ok) return;
|
||||
try {
|
||||
await sw.api.teams.deleteConnection(teamId, conn.id);
|
||||
sw.emit('toast', { message: 'Connection removed', variant: 'success' });
|
||||
load();
|
||||
} catch (e) {
|
||||
sw.emit('toast', { message: e.message, variant: 'error' });
|
||||
}
|
||||
}, [teamId, load]);
|
||||
|
||||
const setField = useCallback((key, val) => {
|
||||
setForm(f => ({ ...f, [key]: val }));
|
||||
}, []);
|
||||
|
||||
const setConfigField = useCallback((key, val) => {
|
||||
setForm(f => ({ ...f, config: { ...f.config, [key]: val } }));
|
||||
}, []);
|
||||
|
||||
const onTypeChange = useCallback((typeVal) => {
|
||||
const ct = connTypes.find(t => t.type === typeVal);
|
||||
setForm(f => ({ ...f, type: typeVal, package_id: ct?.packageId || f.package_id, config: {} }));
|
||||
}, [connTypes]);
|
||||
|
||||
if (connections === null) {
|
||||
return html`<div class="admin-placeholder">Loading connections\u2026</div>`;
|
||||
}
|
||||
|
||||
return html`
|
||||
<div style="margin-bottom:12px;">
|
||||
<button class="btn-md btn-primary" onClick=${openAdd}
|
||||
disabled=${!connTypes.length}>+ Add Connection</button>
|
||||
</div>
|
||||
|
||||
${showForm && html`
|
||||
<div class="admin-card" style="margin-bottom:16px;padding:16px;">
|
||||
<h3 style="margin-top:0;">${editId ? 'Edit Connection' : 'Add Connection'}</h3>
|
||||
${!editId && html`
|
||||
<div class="form-group">
|
||||
<label>Connection Type</label>
|
||||
<select value=${form.type}
|
||||
onChange=${e => onTypeChange(e.target.value)}>
|
||||
${connTypes.map(ct => html`
|
||||
<option value=${ct.type}>${esc(ct.label)}</option>
|
||||
`)}
|
||||
</select>
|
||||
</div>
|
||||
`}
|
||||
<div class="form-group">
|
||||
<label>Name</label>
|
||||
<input type="text" placeholder="e.g. Team Gitea"
|
||||
value=${form.name}
|
||||
onInput=${e => setField('name', e.target.value)} />
|
||||
</div>
|
||||
${selectedType && Object.entries(selectedType.fields || {}).map(([fk, fd]) => html`
|
||||
<div class="form-group" key=${fk}>
|
||||
<label>${esc(fd.label || fk)}${fd.required ? ' *' : ''}${editId && fd.type === 'secret' ? ' (leave blank to keep)' : ''}</label>
|
||||
${fd.type === 'secret' ? html`
|
||||
<input type="password" placeholder="\u2022\u2022\u2022\u2022"
|
||||
value=${form.config[fk] || ''}
|
||||
onInput=${e => setConfigField(fk, e.target.value)} />
|
||||
` : fd.type === 'url' ? html`
|
||||
<input type="url" placeholder="https://..."
|
||||
value=${form.config[fk] || ''}
|
||||
onInput=${e => setConfigField(fk, e.target.value)} />
|
||||
` : html`
|
||||
<input type="text"
|
||||
value=${form.config[fk] || ''}
|
||||
onInput=${e => setConfigField(fk, e.target.value)} />
|
||||
`}
|
||||
</div>
|
||||
`)}
|
||||
<div style="display:flex;gap:8px;margin-top:12px;">
|
||||
<button class="btn-md btn-primary" disabled=${saving}
|
||||
onClick=${save}>
|
||||
${saving ? 'Saving\u2026' : editId ? 'Update' : 'Add'}
|
||||
</button>
|
||||
<button class="btn-md btn-secondary" onClick=${cancel}>Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
`}
|
||||
|
||||
${!connections.length && !showForm && html`
|
||||
<div class="empty-hint">No team connections configured.</div>
|
||||
`}
|
||||
|
||||
${connections.length > 0 && html`
|
||||
<table class="admin-table">
|
||||
<thead><tr><th>Name</th><th>Type</th><th>Status</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
${connections.map(c => html`
|
||||
<tr>
|
||||
<td style="font-weight:500;font-size:13px;">${esc(c.name)}</td>
|
||||
<td style="font-size:12px;color:var(--text-2);">${esc(c.type)}</td>
|
||||
<td style="font-size:12px;">
|
||||
${c.is_active
|
||||
? html`<span style="color:var(--green);">\u25CF Active</span>`
|
||||
: html`<span style="color:var(--text-3);">\u25CB Inactive</span>`}
|
||||
</td>
|
||||
<td class="admin-actions-cell" style="white-space:nowrap;">
|
||||
<button class="icon-btn" title="Edit" onClick=${() => openEdit(c)}>
|
||||
\u{270F}\u{FE0F}
|
||||
</button>
|
||||
<button class="icon-btn icon-btn-danger" title="Delete"
|
||||
onClick=${() => del(c)}>
|
||||
\u{1F5D1}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
`)}
|
||||
</tbody>
|
||||
</table>
|
||||
`}
|
||||
`;
|
||||
}
|
||||
@@ -19,6 +19,7 @@ import { DialogStack } from '../../shell/dialog-stack.js';
|
||||
const SECTIONS = [
|
||||
{ key: 'members', label: 'Members' },
|
||||
{ key: 'providers', label: 'Providers' },
|
||||
{ key: 'connections', label: 'Connections' },
|
||||
{ key: 'personas', label: 'Personas' },
|
||||
{ key: 'knowledge', label: 'Knowledge' },
|
||||
{ key: 'groups', label: 'Groups' },
|
||||
@@ -32,7 +33,8 @@ const SECTIONS = [
|
||||
// ── Lazy section imports ────────────────────
|
||||
const sectionModules = {
|
||||
members: () => import('./members.js'),
|
||||
providers: () => import('./providers.js'),
|
||||
providers: () => import('./providers.js'),
|
||||
connections: () => import('./connections.js'),
|
||||
personas: () => import('./personas.js'),
|
||||
knowledge: () => import('./knowledge.js'),
|
||||
groups: () => import('./groups.js'),
|
||||
|
||||
Reference in New Issue
Block a user