Changeset 0.38.1 (#234)
Co-authored-by: gobha <jasafpro@gmail.com> Co-committed-by: gobha <jasafpro@gmail.com>
This commit is contained in:
37
CHANGELOG.md
37
CHANGELOG.md
@@ -1,5 +1,42 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## [0.38.1.0] — 2026-03-25
|
||||||
|
|
||||||
|
### Summary
|
||||||
|
|
||||||
|
Extension Connections. Scoped credential management for extensions that
|
||||||
|
integrate with external services. Same scope/resolution pattern as LLM
|
||||||
|
providers (personal → team → global), sharable across packages.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Schema:** `ext_connections` table with `(type, scope, owner_id, name)`
|
||||||
|
unique constraint. Secret fields encrypted at rest using vault pattern.
|
||||||
|
(`server/database/migrations/022_ext_connections.sql`)
|
||||||
|
- **Store:** `ConnectionStore` interface with CRUD, scoped queries, and
|
||||||
|
resolution chain (personal → team → global).
|
||||||
|
(`server/store/interfaces.go`, `postgres/ext_connection.go`, `sqlite/ext_connection.go`)
|
||||||
|
- **Handlers:** Three scope tiers of REST endpoints:
|
||||||
|
- Personal: `GET/POST /connections`, `GET/PUT/DELETE /connections/:id`, `GET /connections/resolve`
|
||||||
|
- Team: `GET/POST /teams/:teamId/connections`, `PUT/DELETE /teams/:teamId/connections/:id`
|
||||||
|
- Admin: `GET/POST /admin/connections`, `PUT/DELETE /admin/connections/:id`
|
||||||
|
(`server/handlers/connections.go`, `team_connections.go`, `admin_connections.go`)
|
||||||
|
- **Starlark module:** `connections.get(type)`, `connections.get(type, name=)`,
|
||||||
|
`connections.list(type)` — gated by `connections.read` permission.
|
||||||
|
(`server/sandbox/connections_module.go`)
|
||||||
|
- **UI:** Connection management sections in Admin, Team Admin, and Settings
|
||||||
|
surfaces with dynamic form rendering from manifest field schemas.
|
||||||
|
(`src/js/sw/surfaces/admin/connections.js`, `team-admin/connections.js`, `settings/connections.js`)
|
||||||
|
- **SDK:** `sw.api.connections.*`, `sw.api.teams.{create,update,delete}Connection()`,
|
||||||
|
`sw.api.admin.connections.*` domain methods.
|
||||||
|
(`src/js/sw/sdk/api-domains.js`)
|
||||||
|
- **ICD:** Extension connections section with all endpoint specs.
|
||||||
|
(`docs/ICD/extensions.md`)
|
||||||
|
- **OpenAPI:** Connection endpoint schemas (personal, team, admin, resolve).
|
||||||
|
(`server/static/openapi.yaml`)
|
||||||
|
- **SDK test runner:** `connections` domain — CRUD + resolve chain tests.
|
||||||
|
(`packages/sdk-test-runner/js/domains/connections.js`)
|
||||||
|
|
||||||
## [0.38.0.0] — 2026-03-25
|
## [0.38.0.0] — 2026-03-25
|
||||||
|
|
||||||
### Summary
|
### Summary
|
||||||
|
|||||||
@@ -337,6 +337,83 @@ to disk.
|
|||||||
- `400` — `starlark package missing entry point "script.star"` (or
|
- `400` — `starlark package missing entry point "script.star"` (or
|
||||||
custom `entry_point` value)
|
custom `entry_point` value)
|
||||||
|
|
||||||
|
## 5. Extension Connections (v0.38.1)
|
||||||
|
|
||||||
|
Scoped credential management for extensions that integrate with
|
||||||
|
external services. Same scope/resolution pattern as provider configs
|
||||||
|
(personal → team → global).
|
||||||
|
|
||||||
|
### Personal Connections
|
||||||
|
|
||||||
|
**Auth:** Authenticated user
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /connections → {"data": [...connection summaries]}
|
||||||
|
POST /connections ← {"type","package_id","name","config"} → {"id","type","name"}
|
||||||
|
GET /connections/:id → connection summary (owned only)
|
||||||
|
PUT /connections/:id ← {"name?","config?"} → {"message":"connection updated"}
|
||||||
|
DELETE /connections/:id → {"message":"connection deleted"}
|
||||||
|
GET /connections/resolve → resolved connection (decrypted config)
|
||||||
|
?type=gitea&name=Work+Gitea
|
||||||
|
```
|
||||||
|
|
||||||
|
### Team Connections
|
||||||
|
|
||||||
|
**Auth:** Team admin (RequireTeamAdmin middleware)
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /teams/:teamId/connections → {"data": [...connection summaries]}
|
||||||
|
POST /teams/:teamId/connections ← {"type","package_id","name","config"} → {"id","type","name"}
|
||||||
|
PUT /teams/:teamId/connections/:id ← {"name?","config?"} → {"message":"connection updated"}
|
||||||
|
DELETE /teams/:teamId/connections/:id → {"message":"connection deleted"}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Admin (Global) Connections
|
||||||
|
|
||||||
|
**Auth:** Admin
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /admin/connections → {"data": [...connection summaries]}
|
||||||
|
POST /admin/connections ← {"type","package_id","name","config"} → {"id","type","name"}
|
||||||
|
PUT /admin/connections/:id ← {"name?","config?"} → {"message":"connection updated"}
|
||||||
|
DELETE /admin/connections/:id → {"message":"connection deleted"}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Connection summary** (list/get responses — secrets masked):
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "uuid", "type": "gitea", "package_id": "git-board",
|
||||||
|
"scope": "personal", "name": "Work Gitea",
|
||||||
|
"is_active": true, "has_config": true,
|
||||||
|
"created_at": "2026-03-25T...", "updated_at": "2026-03-25T..."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Scope resolution:** `GET /connections/resolve?type=gitea` resolves
|
||||||
|
via personal → team → global chain. Returns full connection with
|
||||||
|
decrypted config. Optional `name` parameter for named resolution.
|
||||||
|
|
||||||
|
**Config encryption:** Fields with `type: "secret"` in the package
|
||||||
|
manifest's `connections[].fields` are encrypted at rest using the
|
||||||
|
same vault pattern as provider API keys. The handler layer encrypts
|
||||||
|
on write and decrypts on read. The store is encryption-agnostic.
|
||||||
|
|
||||||
|
**Unique constraint:** `(type, scope, owner_id, name)` — a user cannot
|
||||||
|
have two connections of the same type with the same name.
|
||||||
|
|
||||||
|
**Starlark module:** Extensions with `connections.read` permission
|
||||||
|
get a `connections` module:
|
||||||
|
```python
|
||||||
|
conn = connections.get("gitea") # scope chain
|
||||||
|
conn = connections.get("gitea", name="Work Gitea") # named
|
||||||
|
all = connections.list("gitea") # all accessible
|
||||||
|
```
|
||||||
|
|
||||||
|
Each returned dict contains `id`, `type`, `name`, `scope` plus all
|
||||||
|
config fields with secrets decrypted.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
### Builtin Seeding
|
### Builtin Seeding
|
||||||
|
|
||||||
On startup, `SeedBuiltinExtensions()` scans `extensions/builtin/`
|
On startup, `SeedBuiltinExtensions()` scans `extensions/builtin/`
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ v0.9.x–v0.28.7 Foundation through Platform Polish ✅
|
|||||||
│ v0.37.17–18 Surfaces ✅ │
|
│ v0.37.17–18 Surfaces ✅ │
|
||||||
│ v0.37.19 Tag ✅ │
|
│ v0.37.19 Tag ✅ │
|
||||||
│ │ │
|
│ │ │
|
||||||
v0.38.0–.4 │ │
|
v0.38.0–.5 │ │
|
||||||
Extension │ │
|
Extension │ │
|
||||||
Continuation │ │
|
Continuation │ │
|
||||||
│ │ │
|
│ │ │
|
||||||
@@ -280,10 +280,11 @@ See design docs for full specifications.
|
|||||||
| Version | Summary | Design Doc |
|
| Version | Summary | Design Doc |
|
||||||
|---------|---------|------------|
|
|---------|---------|------------|
|
||||||
| v0.38.0 ✅ | Multi-file Starlark | [DESIGN-MULTI-FILE-STARLARK.md](DESIGN-MULTI-FILE-STARLARK.md) — `load()` support, disk-based scripts, package-scoped loader. Prerequisite for libraries. 5 backend changesets. |
|
| v0.38.0 ✅ | Multi-file Starlark | [DESIGN-MULTI-FILE-STARLARK.md](DESIGN-MULTI-FILE-STARLARK.md) — `load()` support, disk-based scripts, package-scoped loader. Prerequisite for libraries. 5 backend changesets. |
|
||||||
| v0.38.1 | Extension Connections | [DESIGN-EXT-CONNECTIONS-LIBRARIES.md](DESIGN-EXT-CONNECTIONS-LIBRARIES.md) Part 1 — `ext_connections` table, scoped CRUD (personal → team → global resolution), `connections` Starlark module, 3 management UIs. |
|
| v0.38.1 ✅ | Extension Connections | [DESIGN-EXT-CONNECTIONS-LIBRARIES.md](DESIGN-EXT-CONNECTIONS-LIBRARIES.md) Part 1 — `ext_connections` table, scoped CRUD (personal → team → global resolution), `connections` Starlark module, 3 management UIs. |
|
||||||
| v0.38.2 | Library Packages | Part 2 — `library` package type, `ext_dependencies` table, `lib.load()` with per-library permission context, dependency resolution, uninstall protection. |
|
| v0.38.2 | Library Packages | Part 2 — `library` package type, `ext_dependencies` table, `lib.load()` with per-library permission context, dependency resolution, uninstall protection. |
|
||||||
| v0.38.3 | Full Composition | Part 3 — Libraries declare connection types for consumers, reference `gitea-client` library. |
|
| v0.38.3 | Extension Config Sections | Manifest-driven `config_section` — packages declare a Preact component that the Settings/Admin surfaces discover and lazy-load as a nav section. Enables headless extensions (no surface) and libraries to own their configuration UX. Libraries can provide rich config for their connection types (OAuth flows, test buttons, pickers). |
|
||||||
| v0.38.4 | Git-board Rewrite | Capstone validation — rewrite git-board as multi-package, multi-platform extension (Gitea/GitLab/GitHub). Proves the entire extension architecture works end-to-end. Reference implementation for future extension authors. |
|
| v0.38.4 | Full Composition | Part 3 — Libraries declare connection types for consumers, reference `gitea-client` library. |
|
||||||
|
| v0.38.5 | Git-board Rewrite | Capstone validation — rewrite git-board as multi-package, multi-platform extension (Gitea/GitLab/GitHub). Proves the entire extension architecture works end-to-end. Reference implementation for future extension authors. |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
117
packages/sdk-test-runner/js/domains/connections.js
Normal file
117
packages/sdk-test-runner/js/domains/connections.js
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
/**
|
||||||
|
* SDK Test Runner — Domain: connections (v0.38.1)
|
||||||
|
*
|
||||||
|
* Tests extension connection CRUD and scope resolution.
|
||||||
|
* Requires admin role (global connection CRUD).
|
||||||
|
*/
|
||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
var T = window.SDKR;
|
||||||
|
if (!T) return;
|
||||||
|
|
||||||
|
T.registerDomain('connections', async function () {
|
||||||
|
|
||||||
|
if (!window.sw || !sw.isAdmin) {
|
||||||
|
await T.test('connections', 'guard', 'skip — not admin', async function () {
|
||||||
|
T.assert(true, 'skipped (user is not admin)');
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var globalId = null;
|
||||||
|
var personalId = null;
|
||||||
|
|
||||||
|
// ── Global CRUD ──────────────────────────
|
||||||
|
|
||||||
|
await T.test('connections', 'admin-create', 'create global connection', async function () {
|
||||||
|
var r = await sw.api.admin.connections.create({
|
||||||
|
type: 'sdk-test-conn',
|
||||||
|
package_id: 'sdk-test-pkg',
|
||||||
|
name: 'Global Test',
|
||||||
|
config: { base_url: 'https://test.example.com', org: 'test-org' }
|
||||||
|
});
|
||||||
|
T.assert(r && r.id, 'should return id');
|
||||||
|
T.assert(r.type === 'sdk-test-conn', 'type matches');
|
||||||
|
globalId = r.id;
|
||||||
|
});
|
||||||
|
|
||||||
|
T.cleanup.push(async function () {
|
||||||
|
if (globalId) try { await sw.api.admin.connections.del(globalId); } catch (_) {}
|
||||||
|
});
|
||||||
|
|
||||||
|
await T.test('connections', 'admin-list', 'list global connections includes created', async function () {
|
||||||
|
var r = await sw.api.admin.connections.list();
|
||||||
|
T.assert(Array.isArray(r), 'should be array');
|
||||||
|
var found = r.find(function (c) { return c.id === globalId; });
|
||||||
|
T.assert(found, 'created connection should be in list');
|
||||||
|
T.assert(found.type === 'sdk-test-conn', 'type matches');
|
||||||
|
T.assert(found.name === 'Global Test', 'name matches');
|
||||||
|
});
|
||||||
|
|
||||||
|
await T.test('connections', 'admin-update', 'update global connection', async function () {
|
||||||
|
await sw.api.admin.connections.update(globalId, { name: 'Global Test Updated' });
|
||||||
|
var list = await sw.api.admin.connections.list();
|
||||||
|
var found = list.find(function (c) { return c.id === globalId; });
|
||||||
|
T.assert(found && found.name === 'Global Test Updated', 'name should be updated');
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Personal CRUD ────────────────────────
|
||||||
|
|
||||||
|
await T.test('connections', 'personal-create', 'create personal connection', async function () {
|
||||||
|
var r = await sw.api.connections.create({
|
||||||
|
type: 'sdk-test-conn',
|
||||||
|
package_id: 'sdk-test-pkg',
|
||||||
|
name: 'Personal Test',
|
||||||
|
config: { base_url: 'https://personal.example.com' }
|
||||||
|
});
|
||||||
|
T.assert(r && r.id, 'should return id');
|
||||||
|
personalId = r.id;
|
||||||
|
});
|
||||||
|
|
||||||
|
T.cleanup.push(async function () {
|
||||||
|
if (personalId) try { await sw.api.connections.del(personalId); } catch (_) {}
|
||||||
|
});
|
||||||
|
|
||||||
|
await T.test('connections', 'personal-list', 'list personal connections', async function () {
|
||||||
|
var r = await sw.api.connections.list();
|
||||||
|
T.assert(Array.isArray(r), 'should be array');
|
||||||
|
var found = r.find(function (c) { return c.id === personalId; });
|
||||||
|
T.assert(found, 'personal connection in list');
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Scope Resolution ─────────────────────
|
||||||
|
|
||||||
|
await T.test('connections', 'resolve-prefers-personal', 'resolve prefers personal over global', async function () {
|
||||||
|
var r = await sw.api.connections.resolve('sdk-test-conn');
|
||||||
|
T.assert(r && r.id, 'should resolve');
|
||||||
|
// Personal scope should win over global
|
||||||
|
T.assert(r.scope === 'personal', 'should resolve personal scope first');
|
||||||
|
T.assert(r.id === personalId, 'should be the personal connection');
|
||||||
|
});
|
||||||
|
|
||||||
|
await T.test('connections', 'resolve-by-name', 'resolve by name returns specific connection', async function () {
|
||||||
|
var r = await sw.api.connections.resolve('sdk-test-conn', 'Global Test Updated');
|
||||||
|
T.assert(r && r.id, 'should resolve');
|
||||||
|
T.assert(r.id === globalId, 'should match global connection by name');
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Cleanup ──────────────────────────────
|
||||||
|
|
||||||
|
await T.test('connections', 'personal-delete', 'delete personal connection', async function () {
|
||||||
|
await sw.api.connections.del(personalId);
|
||||||
|
var list = await sw.api.connections.list();
|
||||||
|
var found = list.find(function (c) { return c.id === personalId; });
|
||||||
|
T.assert(!found, 'should be deleted');
|
||||||
|
personalId = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
await T.test('connections', 'admin-delete', 'delete global connection', async function () {
|
||||||
|
await sw.api.admin.connections.del(globalId);
|
||||||
|
var list = await sw.api.admin.connections.list();
|
||||||
|
var found = list.find(function (c) { return c.id === globalId; });
|
||||||
|
T.assert(!found, 'should be deleted');
|
||||||
|
globalId = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
});
|
||||||
|
})();
|
||||||
@@ -75,6 +75,7 @@
|
|||||||
'domains/misc.js',
|
'domains/misc.js',
|
||||||
'domains/admin.js',
|
'domains/admin.js',
|
||||||
'domains/packages.js',
|
'domains/packages.js',
|
||||||
|
'domains/connections.js',
|
||||||
// UI (must come last)
|
// UI (must come last)
|
||||||
'ui.js'
|
'ui.js'
|
||||||
];
|
];
|
||||||
|
|||||||
37
server/database/migrations/022_ext_connections.sql
Normal file
37
server/database/migrations/022_ext_connections.sql
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
-- ==========================================
|
||||||
|
-- Chat Switchboard — 022 Extension Connections
|
||||||
|
-- ==========================================
|
||||||
|
-- v0.38.1: Scoped credential management for extensions.
|
||||||
|
-- Same scope/resolution pattern as provider_configs.
|
||||||
|
-- ==========================================
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS ext_connections (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
type TEXT NOT NULL,
|
||||||
|
package_id TEXT NOT NULL,
|
||||||
|
scope VARCHAR(10) NOT NULL
|
||||||
|
CHECK (scope IN ('global', 'team', 'personal')),
|
||||||
|
owner_id TEXT NOT NULL DEFAULT '',
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
config JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||||
|
is_active BOOLEAN DEFAULT true,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
UNIQUE(type, scope, owner_id, name)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_ext_connections_type ON ext_connections(type);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_ext_connections_scope ON ext_connections(scope);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_ext_connections_owner ON ext_connections(owner_id) WHERE owner_id != '';
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_ext_connections_active ON ext_connections(is_active) WHERE is_active = true;
|
||||||
|
|
||||||
|
DROP TRIGGER IF EXISTS ext_connections_updated_at ON ext_connections;
|
||||||
|
CREATE TRIGGER ext_connections_updated_at BEFORE UPDATE ON ext_connections
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION update_updated_at();
|
||||||
|
|
||||||
|
COMMENT ON TABLE ext_connections IS 'v0.38.1: Extension connection credentials (scoped, encrypted secrets in config JSON)';
|
||||||
|
COMMENT ON COLUMN ext_connections.type IS 'Connection type identifier, shared across packages (e.g. "gitea", "github")';
|
||||||
|
COMMENT ON COLUMN ext_connections.package_id IS 'Declaring package ID (UI attribution only, not access control)';
|
||||||
|
COMMENT ON COLUMN ext_connections.scope IS 'global=admin-managed, team=team-scoped, personal=user-owned';
|
||||||
|
COMMENT ON COLUMN ext_connections.owner_id IS 'Empty for global; team_id for team scope; user_id for personal scope';
|
||||||
|
COMMENT ON COLUMN ext_connections.config IS 'JSON config blob; secret-type fields encrypted at rest by handler layer';
|
||||||
24
server/database/migrations/sqlite/022_ext_connections.sql
Normal file
24
server/database/migrations/sqlite/022_ext_connections.sql
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
-- ==========================================
|
||||||
|
-- Chat Switchboard — 022 Extension Connections
|
||||||
|
-- ==========================================
|
||||||
|
-- v0.38.1: Scoped credential management for extensions.
|
||||||
|
-- SQLite version (no partial indexes, TEXT types).
|
||||||
|
-- ==========================================
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS ext_connections (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
type TEXT NOT NULL,
|
||||||
|
package_id TEXT NOT NULL,
|
||||||
|
scope TEXT NOT NULL CHECK (scope IN ('global', 'team', 'personal')),
|
||||||
|
owner_id TEXT NOT NULL DEFAULT '',
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
config TEXT NOT NULL DEFAULT '{}',
|
||||||
|
is_active INTEGER DEFAULT 1,
|
||||||
|
created_at TEXT,
|
||||||
|
updated_at TEXT,
|
||||||
|
UNIQUE(type, scope, owner_id, name)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_ext_connections_type ON ext_connections(type);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_ext_connections_scope ON ext_connections(scope);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_ext_connections_owner ON ext_connections(owner_id);
|
||||||
112
server/handlers/admin_connections.go
Normal file
112
server/handlers/admin_connections.go
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
// v0.38.1: Extension connection handlers — admin/global scope.
|
||||||
|
// Methods on AdminHandler, mirrors admin provider config pattern.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"chat-switchboard/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ListGlobalConnections returns all global-scope connections.
|
||||||
|
func (h *AdminHandler) ListGlobalConnections(c *gin.Context) {
|
||||||
|
conns, err := h.stores.Connections.ListGlobal(c.Request.Context())
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list connections"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"data": maskConnectionSecrets(conns)})
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateGlobalConnection creates a global-scope connection.
|
||||||
|
func (h *AdminHandler) CreateGlobalConnection(c *gin.Context) {
|
||||||
|
var req struct {
|
||||||
|
Type string `json:"type" binding:"required"`
|
||||||
|
PackageID string `json:"package_id" binding:"required"`
|
||||||
|
Name string `json:"name" binding:"required"`
|
||||||
|
Config models.JSONMap `json:"config"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ch := &ConnectionHandler{stores: h.stores, vault: h.vault}
|
||||||
|
secretFields := ch.lookupSecretFields(c, req.PackageID, req.Type)
|
||||||
|
encConfig := ch.encryptSecrets(req.Config, secretFields, models.ScopeGlobal, "")
|
||||||
|
|
||||||
|
conn := &models.ExtConnection{
|
||||||
|
Type: req.Type,
|
||||||
|
PackageID: req.PackageID,
|
||||||
|
Scope: models.ScopeGlobal,
|
||||||
|
OwnerID: "",
|
||||||
|
Name: req.Name,
|
||||||
|
Config: encConfig,
|
||||||
|
IsActive: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.stores.Connections.Create(c.Request.Context(), conn); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create connection"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusCreated, gin.H{
|
||||||
|
"id": conn.ID,
|
||||||
|
"type": conn.Type,
|
||||||
|
"name": conn.Name,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateGlobalConnection updates a global-scope connection.
|
||||||
|
func (h *AdminHandler) UpdateGlobalConnection(c *gin.Context) {
|
||||||
|
id := c.Param("id")
|
||||||
|
|
||||||
|
existing, err := h.stores.Connections.GetByID(c.Request.Context(), id)
|
||||||
|
if err != nil || existing.Scope != models.ScopeGlobal {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "connection not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req struct {
|
||||||
|
Name *string `json:"name,omitempty"`
|
||||||
|
Config models.JSONMap `json:"config,omitempty"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
patch := models.ExtConnectionPatch{Name: req.Name}
|
||||||
|
if req.Config != nil {
|
||||||
|
ch := &ConnectionHandler{stores: h.stores, vault: h.vault}
|
||||||
|
secretFields := ch.lookupSecretFields(c, existing.PackageID, existing.Type)
|
||||||
|
patch.Config = ch.encryptSecrets(req.Config, secretFields, models.ScopeGlobal, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.stores.Connections.Update(c.Request.Context(), id, patch); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update connection"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "connection updated"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteGlobalConnection deletes a global-scope connection.
|
||||||
|
func (h *AdminHandler) DeleteGlobalConnection(c *gin.Context) {
|
||||||
|
id := c.Param("id")
|
||||||
|
|
||||||
|
n, err := h.stores.Connections.DeleteByIDAndScope(c.Request.Context(), id, models.ScopeGlobal, "")
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete connection"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if n == 0 {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "connection not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "connection deleted"})
|
||||||
|
}
|
||||||
101
server/handlers/connection_resolver.go
Normal file
101
server/handlers/connection_resolver.go
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
// v0.38.1: ConnectionResolver bridges the sandbox connections module
|
||||||
|
// to the store + vault layers. Implements sandbox.ConnectionResolver.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
|
||||||
|
"chat-switchboard/crypto"
|
||||||
|
"chat-switchboard/models"
|
||||||
|
"chat-switchboard/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ConnectionResolverAdapter implements sandbox.ConnectionResolver.
|
||||||
|
type ConnectionResolverAdapter struct {
|
||||||
|
stores store.Stores
|
||||||
|
vault *crypto.KeyResolver
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewConnectionResolverAdapter(s store.Stores, vault *crypto.KeyResolver) *ConnectionResolverAdapter {
|
||||||
|
return &ConnectionResolverAdapter{stores: s, vault: vault}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve returns a single connection with secrets decrypted.
|
||||||
|
func (r *ConnectionResolverAdapter) Resolve(ctx context.Context, userID, connType, name string) (*models.ExtConnection, error) {
|
||||||
|
conn, err := r.stores.Connections.Resolve(ctx, userID, connType, name)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
r.decryptInPlace(ctx, conn)
|
||||||
|
return conn, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResolveAll returns all accessible connections with secrets decrypted.
|
||||||
|
func (r *ConnectionResolverAdapter) ResolveAll(ctx context.Context, userID, connType string) ([]models.ExtConnection, error) {
|
||||||
|
conns, err := r.stores.Connections.ResolveAll(ctx, userID, connType)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for i := range conns {
|
||||||
|
r.decryptInPlace(ctx, &conns[i])
|
||||||
|
}
|
||||||
|
return conns, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// decryptInPlace decrypts secret fields in the connection's config.
|
||||||
|
func (r *ConnectionResolverAdapter) decryptInPlace(ctx context.Context, conn *models.ExtConnection) {
|
||||||
|
if conn == nil || conn.Config == nil || r.vault == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
secretFields := r.lookupSecretFields(ctx, conn.PackageID, conn.Type)
|
||||||
|
for k, v := range conn.Config {
|
||||||
|
if !secretFields[k] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
m, ok := v.(map[string]interface{})
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
enc := toBytes(m["enc"])
|
||||||
|
nonce := toBytes(m["nonce"])
|
||||||
|
if len(enc) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
plaintext, err := r.vault.Decrypt(enc, nonce, conn.Scope, conn.OwnerID)
|
||||||
|
if err == nil {
|
||||||
|
conn.Config[k] = plaintext
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// lookupSecretFields reads the package manifest to identify secret fields.
|
||||||
|
func (r *ConnectionResolverAdapter) lookupSecretFields(ctx context.Context, packageID, connType string) map[string]bool {
|
||||||
|
result := map[string]bool{}
|
||||||
|
pkg, err := r.stores.Packages.Get(ctx, packageID)
|
||||||
|
if err != nil || pkg == nil {
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
connsRaw, ok := pkg.Manifest["connections"]
|
||||||
|
if !ok {
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
connsJSON, _ := json.Marshal(connsRaw)
|
||||||
|
var conns []struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Fields map[string]map[string]string `json:"fields"`
|
||||||
|
}
|
||||||
|
json.Unmarshal(connsJSON, &conns)
|
||||||
|
for _, cd := range conns {
|
||||||
|
if cd.Type == connType {
|
||||||
|
for fieldName, fieldDef := range cd.Fields {
|
||||||
|
if fieldDef["type"] == "secret" {
|
||||||
|
result[fieldName] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
299
server/handlers/connections.go
Normal file
299
server/handlers/connections.go
Normal file
@@ -0,0 +1,299 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
// v0.38.1: Extension connection handlers — personal scope + resolution.
|
||||||
|
// Pattern follows apiconfigs.go (ProviderConfigHandler).
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"chat-switchboard/crypto"
|
||||||
|
"chat-switchboard/models"
|
||||||
|
"chat-switchboard/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ConnectionHandler handles user-facing extension connection endpoints.
|
||||||
|
type ConnectionHandler struct {
|
||||||
|
stores store.Stores
|
||||||
|
vault *crypto.KeyResolver
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewConnectionHandler(s store.Stores, vault *crypto.KeyResolver) *ConnectionHandler {
|
||||||
|
return &ConnectionHandler{stores: s, vault: vault}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListConnections returns the user's personal connections.
|
||||||
|
func (h *ConnectionHandler) ListConnections(c *gin.Context) {
|
||||||
|
userID := getUserID(c)
|
||||||
|
conns, err := h.stores.Connections.ListForUser(c.Request.Context(), userID)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list connections"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"data": maskConnectionSecrets(conns)})
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateConnection creates a personal-scope connection.
|
||||||
|
func (h *ConnectionHandler) CreateConnection(c *gin.Context) {
|
||||||
|
userID := getUserID(c)
|
||||||
|
|
||||||
|
var req struct {
|
||||||
|
Type string `json:"type" binding:"required"`
|
||||||
|
PackageID string `json:"package_id" binding:"required"`
|
||||||
|
Name string `json:"name" binding:"required"`
|
||||||
|
Config models.JSONMap `json:"config"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
secretFields := h.lookupSecretFields(c, req.PackageID, req.Type)
|
||||||
|
encConfig := h.encryptSecrets(req.Config, secretFields, models.ScopePersonal, userID)
|
||||||
|
|
||||||
|
conn := &models.ExtConnection{
|
||||||
|
Type: req.Type,
|
||||||
|
PackageID: req.PackageID,
|
||||||
|
Scope: models.ScopePersonal,
|
||||||
|
OwnerID: userID,
|
||||||
|
Name: req.Name,
|
||||||
|
Config: encConfig,
|
||||||
|
IsActive: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.stores.Connections.Create(c.Request.Context(), conn); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create connection"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusCreated, gin.H{
|
||||||
|
"id": conn.ID,
|
||||||
|
"type": conn.Type,
|
||||||
|
"name": conn.Name,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetConnection returns a single connection by ID.
|
||||||
|
func (h *ConnectionHandler) GetConnection(c *gin.Context) {
|
||||||
|
userID := getUserID(c)
|
||||||
|
id := c.Param("id")
|
||||||
|
|
||||||
|
conn, err := h.stores.Connections.GetByID(c.Request.Context(), id)
|
||||||
|
if err != nil || conn.Scope != models.ScopePersonal || conn.OwnerID != userID {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "connection not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, maskSingleConnection(conn))
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateConnection updates a personal connection.
|
||||||
|
func (h *ConnectionHandler) UpdateConnection(c *gin.Context) {
|
||||||
|
userID := getUserID(c)
|
||||||
|
id := c.Param("id")
|
||||||
|
|
||||||
|
existing, err := h.stores.Connections.GetByID(c.Request.Context(), id)
|
||||||
|
if err != nil || existing.Scope != models.ScopePersonal || existing.OwnerID != userID {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": "can only update your own connections"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req struct {
|
||||||
|
Name *string `json:"name,omitempty"`
|
||||||
|
Config models.JSONMap `json:"config,omitempty"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
patch := models.ExtConnectionPatch{Name: req.Name}
|
||||||
|
if req.Config != nil {
|
||||||
|
secretFields := h.lookupSecretFields(c, existing.PackageID, existing.Type)
|
||||||
|
patch.Config = h.encryptSecrets(req.Config, secretFields, models.ScopePersonal, userID)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.stores.Connections.Update(c.Request.Context(), id, patch); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update connection"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "connection updated"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteConnection deletes a personal connection.
|
||||||
|
func (h *ConnectionHandler) DeleteConnection(c *gin.Context) {
|
||||||
|
userID := getUserID(c)
|
||||||
|
id := c.Param("id")
|
||||||
|
|
||||||
|
n, err := h.stores.Connections.DeleteByIDAndScope(c.Request.Context(), id, models.ScopePersonal, userID)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete connection"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if n == 0 {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "connection not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "connection deleted"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResolveConnection resolves a connection via the scope chain.
|
||||||
|
// GET /connections/resolve?type=gitea&name=Work+Gitea
|
||||||
|
func (h *ConnectionHandler) ResolveConnection(c *gin.Context) {
|
||||||
|
userID := getUserID(c)
|
||||||
|
connType := c.Query("type")
|
||||||
|
if connType == "" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "type query parameter required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
name := c.Query("name")
|
||||||
|
|
||||||
|
conn, err := h.stores.Connections.Resolve(c.Request.Context(), userID, connType, name)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "no connection found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decrypt secrets for the resolver response (used by frontend to test connections).
|
||||||
|
secretFields := h.lookupSecretFields(c, conn.PackageID, conn.Type)
|
||||||
|
conn.Config = h.decryptSecrets(conn.Config, secretFields, conn.Scope, conn.OwnerID)
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, conn)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Encryption helpers ─────────────────────────────
|
||||||
|
|
||||||
|
// lookupSecretFields reads the package manifest to identify which config
|
||||||
|
// fields are of type "secret" for a given connection type.
|
||||||
|
func (h *ConnectionHandler) lookupSecretFields(c *gin.Context, packageID, connType string) map[string]bool {
|
||||||
|
result := map[string]bool{}
|
||||||
|
pkg, err := h.stores.Packages.Get(c.Request.Context(), packageID)
|
||||||
|
if err != nil || pkg == nil {
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
connsRaw, ok := pkg.Manifest["connections"]
|
||||||
|
if !ok {
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
connsJSON, _ := json.Marshal(connsRaw)
|
||||||
|
var conns []struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Fields map[string]map[string]string `json:"fields"`
|
||||||
|
}
|
||||||
|
json.Unmarshal(connsJSON, &conns)
|
||||||
|
for _, cd := range conns {
|
||||||
|
if cd.Type == connType {
|
||||||
|
for fieldName, fieldDef := range cd.Fields {
|
||||||
|
if fieldDef["type"] == "secret" {
|
||||||
|
result[fieldName] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// encryptSecrets encrypts secret-type fields within a config map.
|
||||||
|
// Non-secret fields pass through unchanged.
|
||||||
|
func (h *ConnectionHandler) encryptSecrets(config models.JSONMap, secretFields map[string]bool, scope, ownerID string) models.JSONMap {
|
||||||
|
if config == nil || h.vault == nil {
|
||||||
|
return config
|
||||||
|
}
|
||||||
|
out := make(models.JSONMap, len(config))
|
||||||
|
for k, v := range config {
|
||||||
|
if secretFields[k] {
|
||||||
|
if s, ok := v.(string); ok && s != "" {
|
||||||
|
enc, nonce, err := h.vault.EncryptForScope(s, scope, ownerID)
|
||||||
|
if err == nil {
|
||||||
|
out[k] = map[string]interface{}{
|
||||||
|
"enc": enc,
|
||||||
|
"nonce": nonce,
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out[k] = v
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// decryptSecrets decrypts secret-type fields within a config map.
|
||||||
|
func (h *ConnectionHandler) decryptSecrets(config models.JSONMap, secretFields map[string]bool, scope, ownerID string) models.JSONMap {
|
||||||
|
if config == nil || h.vault == nil {
|
||||||
|
return config
|
||||||
|
}
|
||||||
|
out := make(models.JSONMap, len(config))
|
||||||
|
for k, v := range config {
|
||||||
|
if secretFields[k] {
|
||||||
|
if m, ok := v.(map[string]interface{}); ok {
|
||||||
|
encRaw, _ := m["enc"]
|
||||||
|
nonceRaw, _ := m["nonce"]
|
||||||
|
// Type-assert to []byte or base64-decode from string
|
||||||
|
enc := toBytes(encRaw)
|
||||||
|
nonce := toBytes(nonceRaw)
|
||||||
|
if len(enc) > 0 {
|
||||||
|
plaintext, err := h.vault.Decrypt(enc, nonce, scope, ownerID)
|
||||||
|
if err == nil {
|
||||||
|
out[k] = plaintext
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out[k] = v
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// toBytes coerces an interface{} to []byte. Handles both []byte (from Go)
|
||||||
|
// and string (from JSON unmarshal of base64).
|
||||||
|
func toBytes(v interface{}) []byte {
|
||||||
|
switch t := v.(type) {
|
||||||
|
case []byte:
|
||||||
|
return t
|
||||||
|
case string:
|
||||||
|
return []byte(t)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Response masking ───────────────────────────────
|
||||||
|
|
||||||
|
// maskConnectionSecrets returns safe connection summaries (no secret values).
|
||||||
|
func maskConnectionSecrets(conns []models.ExtConnection) []map[string]interface{} {
|
||||||
|
out := make([]map[string]interface{}, len(conns))
|
||||||
|
for i, c := range conns {
|
||||||
|
out[i] = map[string]interface{}{
|
||||||
|
"id": c.ID,
|
||||||
|
"type": c.Type,
|
||||||
|
"package_id": c.PackageID,
|
||||||
|
"scope": c.Scope,
|
||||||
|
"name": c.Name,
|
||||||
|
"is_active": c.IsActive,
|
||||||
|
"has_config": len(c.Config) > 0,
|
||||||
|
"created_at": c.CreatedAt.Format("2006-01-02T15:04:05Z"),
|
||||||
|
"updated_at": c.UpdatedAt.Format("2006-01-02T15:04:05Z"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func maskSingleConnection(c *models.ExtConnection) map[string]interface{} {
|
||||||
|
return map[string]interface{}{
|
||||||
|
"id": c.ID,
|
||||||
|
"type": c.Type,
|
||||||
|
"package_id": c.PackageID,
|
||||||
|
"scope": c.Scope,
|
||||||
|
"name": c.Name,
|
||||||
|
"is_active": c.IsActive,
|
||||||
|
"has_config": len(c.Config) > 0,
|
||||||
|
"created_at": c.CreatedAt.Format("2006-01-02T15:04:05Z"),
|
||||||
|
"updated_at": c.UpdatedAt.Format("2006-01-02T15:04:05Z"),
|
||||||
|
}
|
||||||
|
}
|
||||||
118
server/handlers/team_connections.go
Normal file
118
server/handlers/team_connections.go
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
// v0.38.1: Extension connection handlers — team scope.
|
||||||
|
// Methods on TeamHandler, mirrors team_providers.go.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"chat-switchboard/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ListTeamConnections returns connections scoped to a team.
|
||||||
|
func (h *TeamHandler) ListTeamConnections(c *gin.Context) {
|
||||||
|
teamID := getTeamID(c)
|
||||||
|
conns, err := h.stores.Connections.ListForTeam(c.Request.Context(), teamID)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list team connections"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"data": maskConnectionSecrets(conns)})
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateTeamConnection creates a connection scoped to a team.
|
||||||
|
func (h *TeamHandler) CreateTeamConnection(c *gin.Context) {
|
||||||
|
teamID := getTeamID(c)
|
||||||
|
|
||||||
|
var req struct {
|
||||||
|
Type string `json:"type" binding:"required"`
|
||||||
|
PackageID string `json:"package_id" binding:"required"`
|
||||||
|
Name string `json:"name" binding:"required"`
|
||||||
|
Config models.JSONMap `json:"config"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use ConnectionHandler helpers for encryption
|
||||||
|
ch := &ConnectionHandler{stores: h.stores, vault: h.vault}
|
||||||
|
secretFields := ch.lookupSecretFields(c, req.PackageID, req.Type)
|
||||||
|
encConfig := ch.encryptSecrets(req.Config, secretFields, models.ScopeTeam, teamID)
|
||||||
|
|
||||||
|
conn := &models.ExtConnection{
|
||||||
|
Type: req.Type,
|
||||||
|
PackageID: req.PackageID,
|
||||||
|
Scope: models.ScopeTeam,
|
||||||
|
OwnerID: teamID,
|
||||||
|
Name: req.Name,
|
||||||
|
Config: encConfig,
|
||||||
|
IsActive: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.stores.Connections.Create(c.Request.Context(), conn); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create team connection"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusCreated, gin.H{
|
||||||
|
"id": conn.ID,
|
||||||
|
"type": conn.Type,
|
||||||
|
"name": conn.Name,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateTeamConnection updates a team-scoped connection.
|
||||||
|
func (h *TeamHandler) UpdateTeamConnection(c *gin.Context) {
|
||||||
|
teamID := getTeamID(c)
|
||||||
|
id := c.Param("id")
|
||||||
|
|
||||||
|
existing, err := h.stores.Connections.GetByID(c.Request.Context(), id)
|
||||||
|
if err != nil || existing.Scope != models.ScopeTeam || existing.OwnerID != teamID {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "connection not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req struct {
|
||||||
|
Name *string `json:"name,omitempty"`
|
||||||
|
Config models.JSONMap `json:"config,omitempty"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
patch := models.ExtConnectionPatch{Name: req.Name}
|
||||||
|
if req.Config != nil {
|
||||||
|
ch := &ConnectionHandler{stores: h.stores, vault: h.vault}
|
||||||
|
secretFields := ch.lookupSecretFields(c, existing.PackageID, existing.Type)
|
||||||
|
patch.Config = ch.encryptSecrets(req.Config, secretFields, models.ScopeTeam, teamID)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.stores.Connections.Update(c.Request.Context(), id, patch); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update connection"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "connection updated"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteTeamConnection deletes a team-scoped connection.
|
||||||
|
func (h *TeamHandler) DeleteTeamConnection(c *gin.Context) {
|
||||||
|
teamID := getTeamID(c)
|
||||||
|
id := c.Param("id")
|
||||||
|
|
||||||
|
n, err := h.stores.Connections.DeleteByIDAndScope(c.Request.Context(), id, models.ScopeTeam, teamID)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete connection"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if n == 0 {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "connection not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "connection deleted"})
|
||||||
|
}
|
||||||
@@ -408,6 +408,8 @@ func main() {
|
|||||||
)
|
)
|
||||||
// v0.29.1: provider module adapter — bridges sandbox.ProviderResolver to handlers.ResolveProviderConfig
|
// v0.29.1: provider module adapter — bridges sandbox.ProviderResolver to handlers.ResolveProviderConfig
|
||||||
starlarkRunner.SetProviderResolver(handlers.NewProviderResolverAdapter(stores, keyResolver))
|
starlarkRunner.SetProviderResolver(handlers.NewProviderResolverAdapter(stores, keyResolver))
|
||||||
|
// v0.38.1: connections module — extension connection resolution
|
||||||
|
starlarkRunner.SetConnectionResolver(handlers.NewConnectionResolverAdapter(stores, keyResolver))
|
||||||
// v0.29.2: db module — extension namespaced table access
|
// v0.29.2: db module — extension namespaced table access
|
||||||
starlarkRunner.SetDB(database.DB, database.IsPostgres())
|
starlarkRunner.SetDB(database.DB, database.IsPostgres())
|
||||||
// v0.38.0: disk-based script loading + load() support
|
// v0.38.0: disk-based script loading + load() support
|
||||||
@@ -812,6 +814,15 @@ func main() {
|
|||||||
protected.GET("/api-configs/:id/models", provCfg.ListModels)
|
protected.GET("/api-configs/:id/models", provCfg.ListModels)
|
||||||
protected.POST("/api-configs/:id/models/fetch", provCfg.FetchModels)
|
protected.POST("/api-configs/:id/models/fetch", provCfg.FetchModels)
|
||||||
|
|
||||||
|
// Extension Connections (personal scope — v0.38.1)
|
||||||
|
connH := handlers.NewConnectionHandler(stores, keyResolver)
|
||||||
|
protected.GET("/connections", connH.ListConnections)
|
||||||
|
protected.POST("/connections", connH.CreateConnection)
|
||||||
|
protected.GET("/connections/resolve", connH.ResolveConnection)
|
||||||
|
protected.GET("/connections/:id", connH.GetConnection)
|
||||||
|
protected.PUT("/connections/:id", connH.UpdateConnection)
|
||||||
|
protected.DELETE("/connections/:id", connH.DeleteConnection)
|
||||||
|
|
||||||
// Models (unified resolver — replaces scattered endpoints)
|
// Models (unified resolver — replaces scattered endpoints)
|
||||||
modelH := handlers.NewModelHandler(stores)
|
modelH := handlers.NewModelHandler(stores)
|
||||||
if healthStore != nil {
|
if healthStore != nil {
|
||||||
@@ -1044,6 +1055,12 @@ func main() {
|
|||||||
teamScoped.DELETE("/providers/:id", teams.DeleteTeamProvider)
|
teamScoped.DELETE("/providers/:id", teams.DeleteTeamProvider)
|
||||||
teamScoped.GET("/providers/:id/models", teams.ListTeamProviderModels)
|
teamScoped.GET("/providers/:id/models", teams.ListTeamProviderModels)
|
||||||
|
|
||||||
|
// Team connections (v0.38.1)
|
||||||
|
teamScoped.GET("/connections", teams.ListTeamConnections)
|
||||||
|
teamScoped.POST("/connections", teams.CreateTeamConnection)
|
||||||
|
teamScoped.PUT("/connections/:id", teams.UpdateTeamConnection)
|
||||||
|
teamScoped.DELETE("/connections/:id", teams.DeleteTeamConnection)
|
||||||
|
|
||||||
// Team package management (v0.30.0)
|
// Team package management (v0.30.0)
|
||||||
teamPkgH := handlers.NewUserPackageHandler(stores, userPkgDir)
|
teamPkgH := handlers.NewUserPackageHandler(stores, userPkgDir)
|
||||||
teamScoped.POST("/packages/install", teamPkgH.InstallTeamPackage)
|
teamScoped.POST("/packages/install", teamPkgH.InstallTeamPackage)
|
||||||
@@ -1164,6 +1181,12 @@ func main() {
|
|||||||
admin.PUT("/configs/:id", adm.UpdateGlobalConfig)
|
admin.PUT("/configs/:id", adm.UpdateGlobalConfig)
|
||||||
admin.DELETE("/configs/:id", adm.DeleteGlobalConfig)
|
admin.DELETE("/configs/:id", adm.DeleteGlobalConfig)
|
||||||
|
|
||||||
|
// Global Connections (v0.38.1)
|
||||||
|
admin.GET("/connections", adm.ListGlobalConnections)
|
||||||
|
admin.POST("/connections", adm.CreateGlobalConnection)
|
||||||
|
admin.PUT("/connections/:id", adm.UpdateGlobalConnection)
|
||||||
|
admin.DELETE("/connections/:id", adm.DeleteGlobalConnection)
|
||||||
|
|
||||||
// Model Catalog
|
// Model Catalog
|
||||||
admin.GET("/models", adm.ListModelConfigs)
|
admin.GET("/models", adm.ListModelConfigs)
|
||||||
admin.POST("/models/fetch", adm.FetchModels)
|
admin.POST("/models/fetch", adm.FetchModels)
|
||||||
|
|||||||
24
server/models/ext_connection.go
Normal file
24
server/models/ext_connection.go
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
package models
|
||||||
|
|
||||||
|
// ── Extension Connections (v0.38.1) ──────────────────
|
||||||
|
|
||||||
|
// ExtConnection represents a scoped credential/endpoint configuration
|
||||||
|
// for extensions that integrate with external services. Same scope
|
||||||
|
// hierarchy as ProviderConfig: global → team → personal.
|
||||||
|
type ExtConnection struct {
|
||||||
|
BaseModel
|
||||||
|
Type string `json:"type" db:"type"`
|
||||||
|
PackageID string `json:"package_id" db:"package_id"`
|
||||||
|
Scope string `json:"scope" db:"scope"`
|
||||||
|
OwnerID string `json:"owner_id" db:"owner_id"`
|
||||||
|
Name string `json:"name" db:"name"`
|
||||||
|
Config JSONMap `json:"config" db:"config"`
|
||||||
|
IsActive bool `json:"is_active" db:"is_active"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExtConnectionPatch carries partial updates. Nil fields are skipped.
|
||||||
|
type ExtConnectionPatch struct {
|
||||||
|
Name *string `json:"name,omitempty"`
|
||||||
|
Config JSONMap `json:"config,omitempty"`
|
||||||
|
IsActive *bool `json:"is_active,omitempty"`
|
||||||
|
}
|
||||||
@@ -35,6 +35,7 @@ const (
|
|||||||
ExtPermProviderComplete = "provider.complete" // v0.29.1: LLM completion calls
|
ExtPermProviderComplete = "provider.complete" // v0.29.1: LLM completion calls
|
||||||
ExtPermFormValidate = "forms.validate" // v0.29.3: form validation hooks
|
ExtPermFormValidate = "forms.validate" // v0.29.3: form validation hooks
|
||||||
ExtPermWorkflowAccess = "workflow.access" // v0.30.2: workflow definition + stage data access
|
ExtPermWorkflowAccess = "workflow.access" // v0.30.2: workflow definition + stage data access
|
||||||
|
ExtPermConnectionsRead = "connections.read" // v0.38.1: extension connection resolution
|
||||||
)
|
)
|
||||||
|
|
||||||
// ValidExtensionPermissions is the set of recognized permission keys.
|
// ValidExtensionPermissions is the set of recognized permission keys.
|
||||||
@@ -48,6 +49,7 @@ var ValidExtensionPermissions = map[string]bool{
|
|||||||
ExtPermProviderComplete: true,
|
ExtPermProviderComplete: true,
|
||||||
ExtPermFormValidate: true,
|
ExtPermFormValidate: true,
|
||||||
ExtPermWorkflowAccess: true,
|
ExtPermWorkflowAccess: true,
|
||||||
|
ExtPermConnectionsRead: true,
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Extension Permission Model ───────────────
|
// ── Extension Permission Model ───────────────
|
||||||
|
|||||||
107
server/sandbox/connections_module.go
Normal file
107
server/sandbox/connections_module.go
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
// Package sandbox — connections_module.go
|
||||||
|
//
|
||||||
|
// v0.38.1: Connections module for Starlark extensions.
|
||||||
|
// Requires permission: connections.read
|
||||||
|
//
|
||||||
|
// Starlark API:
|
||||||
|
// conn = connections.get("gitea") → dict or None
|
||||||
|
// conn = connections.get("gitea", name="Work Gitea") → dict or None
|
||||||
|
// all = connections.list("gitea") → list of dicts
|
||||||
|
//
|
||||||
|
// Each returned dict contains the connection's config fields with
|
||||||
|
// secrets decrypted. The module delegates to a ConnectionResolver
|
||||||
|
// interface so the runner stays agnostic to the vault.
|
||||||
|
package sandbox
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"go.starlark.net/starlark"
|
||||||
|
"go.starlark.net/starlarkstruct"
|
||||||
|
|
||||||
|
"chat-switchboard/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ConnectionResolver resolves extension connections with secret decryption.
|
||||||
|
// Implemented by the handler layer which owns the vault.
|
||||||
|
type ConnectionResolver interface {
|
||||||
|
// Resolve returns a single connection's decrypted config via the
|
||||||
|
// scope chain (personal → team → global). Empty name = first match.
|
||||||
|
Resolve(ctx context.Context, userID, connType, name string) (*models.ExtConnection, error)
|
||||||
|
|
||||||
|
// ResolveAll returns all accessible connections of a type with
|
||||||
|
// decrypted configs.
|
||||||
|
ResolveAll(ctx context.Context, userID, connType string) ([]models.ExtConnection, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// BuildConnectionsModule creates the "connections" module.
|
||||||
|
func BuildConnectionsModule(ctx context.Context, resolver ConnectionResolver, userID string) *starlarkstruct.Module {
|
||||||
|
return MakeModule("connections", starlark.StringDict{
|
||||||
|
"get": starlark.NewBuiltin("connections.get", func(
|
||||||
|
thread *starlark.Thread, b *starlark.Builtin,
|
||||||
|
args starlark.Tuple, kwargs []starlark.Tuple,
|
||||||
|
) (starlark.Value, error) {
|
||||||
|
var connType string
|
||||||
|
var name string
|
||||||
|
if err := starlark.UnpackArgs(b.Name(), args, kwargs,
|
||||||
|
"type", &connType,
|
||||||
|
"name?", &name,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
conn, err := resolver.Resolve(ctx, userID, connType, name)
|
||||||
|
if err != nil || conn == nil {
|
||||||
|
return starlark.None, nil
|
||||||
|
}
|
||||||
|
return connectionToDict(conn), nil
|
||||||
|
}),
|
||||||
|
|
||||||
|
"list": starlark.NewBuiltin("connections.list", func(
|
||||||
|
thread *starlark.Thread, b *starlark.Builtin,
|
||||||
|
args starlark.Tuple, kwargs []starlark.Tuple,
|
||||||
|
) (starlark.Value, error) {
|
||||||
|
var connType string
|
||||||
|
if err := starlark.UnpackPositionalArgs(b.Name(), args, kwargs, 1, &connType); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
conns, err := resolver.ResolveAll(ctx, userID, connType)
|
||||||
|
if err != nil {
|
||||||
|
return starlark.NewList(nil), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
elems := make([]starlark.Value, len(conns))
|
||||||
|
for i := range conns {
|
||||||
|
elems[i] = connectionToDict(&conns[i])
|
||||||
|
}
|
||||||
|
return starlark.NewList(elems), nil
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// connectionToDict converts an ExtConnection to a Starlark dict.
|
||||||
|
// Config fields are flattened into the top-level dict alongside id/name/type.
|
||||||
|
func connectionToDict(conn *models.ExtConnection) *starlark.Dict {
|
||||||
|
d := starlark.NewDict(4 + len(conn.Config))
|
||||||
|
d.SetKey(starlark.String("id"), starlark.String(conn.ID))
|
||||||
|
d.SetKey(starlark.String("type"), starlark.String(conn.Type))
|
||||||
|
d.SetKey(starlark.String("name"), starlark.String(conn.Name))
|
||||||
|
d.SetKey(starlark.String("scope"), starlark.String(conn.Scope))
|
||||||
|
|
||||||
|
for k, v := range conn.Config {
|
||||||
|
switch val := v.(type) {
|
||||||
|
case string:
|
||||||
|
d.SetKey(starlark.String(k), starlark.String(val))
|
||||||
|
case float64:
|
||||||
|
d.SetKey(starlark.String(k), starlark.Float(val))
|
||||||
|
case bool:
|
||||||
|
d.SetKey(starlark.String(k), starlark.Bool(val))
|
||||||
|
default:
|
||||||
|
// Fallback: coerce to string
|
||||||
|
d.SetKey(starlark.String(k), starlark.String(""))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return d
|
||||||
|
}
|
||||||
@@ -50,9 +50,10 @@ type Runner struct {
|
|||||||
stores store.Stores
|
stores store.Stores
|
||||||
packagesDir string // v0.38.0: disk path for load() support
|
packagesDir string // v0.38.0: disk path for load() support
|
||||||
notifier NotificationSender // nil = notifications module unavailable
|
notifier NotificationSender // nil = notifications module unavailable
|
||||||
resolver ProviderResolver // nil = provider module unavailable
|
resolver ProviderResolver // nil = provider module unavailable
|
||||||
db *sql.DB // nil = db module unavailable
|
connResolver ConnectionResolver // nil = connections module unavailable (v0.38.1)
|
||||||
dbPostgres bool // true = use $N placeholders; false = use ?
|
db *sql.DB // nil = db module unavailable
|
||||||
|
dbPostgres bool // true = use $N placeholders; false = use ?
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewRunner creates a runner with the given sandbox and dependencies.
|
// NewRunner creates a runner with the given sandbox and dependencies.
|
||||||
@@ -73,6 +74,11 @@ func (r *Runner) SetProviderResolver(pr ProviderResolver) {
|
|||||||
r.resolver = pr
|
r.resolver = pr
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetConnectionResolver attaches the connection resolver for the connections module (v0.38.1).
|
||||||
|
func (r *Runner) SetConnectionResolver(cr ConnectionResolver) {
|
||||||
|
r.connResolver = cr
|
||||||
|
}
|
||||||
|
|
||||||
// SetDB attaches the raw database connection for the db module.
|
// SetDB attaches the raw database connection for the db module.
|
||||||
// isPostgres controls whether $N or ? placeholders are used.
|
// isPostgres controls whether $N or ? placeholders are used.
|
||||||
// Call this at startup after database.Connect().
|
// Call this at startup after database.Connect().
|
||||||
@@ -300,6 +306,11 @@ func (r *Runner) buildModules(ctx context.Context, packageID string, manifest ma
|
|||||||
|
|
||||||
case models.ExtPermWorkflowAccess:
|
case models.ExtPermWorkflowAccess:
|
||||||
modules["workflow"] = BuildWorkflowModule(ctx, r.stores)
|
modules["workflow"] = BuildWorkflowModule(ctx, r.stores)
|
||||||
|
|
||||||
|
case models.ExtPermConnectionsRead:
|
||||||
|
if r.connResolver != nil && rc != nil && rc.UserID != "" {
|
||||||
|
modules["connections"] = BuildConnectionsModule(ctx, r.connResolver, rc.UserID)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8820,6 +8820,213 @@ paths:
|
|||||||
$ref: '#/components/responses/Unauthorized'
|
$ref: '#/components/responses/Unauthorized'
|
||||||
'404':
|
'404':
|
||||||
$ref: '#/components/responses/NotFound'
|
$ref: '#/components/responses/NotFound'
|
||||||
|
# ── Extension Connections (v0.38.1) ─────────────────────────────────
|
||||||
|
/api/v1/connections:
|
||||||
|
get:
|
||||||
|
tags: [Extensions]
|
||||||
|
summary: List personal connections
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Personal connections (secrets masked)
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
data:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/ExtConnection'
|
||||||
|
post:
|
||||||
|
tags: [Extensions]
|
||||||
|
summary: Create personal connection
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
required: [type, package_id, name]
|
||||||
|
properties:
|
||||||
|
type: { type: string }
|
||||||
|
package_id: { type: string }
|
||||||
|
name: { type: string }
|
||||||
|
config: { type: object }
|
||||||
|
responses:
|
||||||
|
'201':
|
||||||
|
description: Created
|
||||||
|
/api/v1/connections/{id}:
|
||||||
|
get:
|
||||||
|
tags: [Extensions]
|
||||||
|
summary: Get personal connection
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
parameters:
|
||||||
|
- $ref: '#/components/parameters/ResourceID'
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Connection (secrets masked)
|
||||||
|
put:
|
||||||
|
tags: [Extensions]
|
||||||
|
summary: Update personal connection
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
parameters:
|
||||||
|
- $ref: '#/components/parameters/ResourceID'
|
||||||
|
requestBody:
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
name: { type: string }
|
||||||
|
config: { type: object }
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Updated
|
||||||
|
delete:
|
||||||
|
tags: [Extensions]
|
||||||
|
summary: Delete personal connection
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
parameters:
|
||||||
|
- $ref: '#/components/parameters/ResourceID'
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Deleted
|
||||||
|
/api/v1/connections/resolve:
|
||||||
|
get:
|
||||||
|
tags: [Extensions]
|
||||||
|
summary: Resolve connection via scope chain (personal > team > global)
|
||||||
|
description: |
|
||||||
|
Returns a single connection with decrypted config, resolved via
|
||||||
|
the scope chain. Requires `type` query parameter. Optional `name`
|
||||||
|
for named resolution.
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
parameters:
|
||||||
|
- name: type
|
||||||
|
in: query
|
||||||
|
required: true
|
||||||
|
schema: { type: string }
|
||||||
|
- name: name
|
||||||
|
in: query
|
||||||
|
schema: { type: string }
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Resolved connection with decrypted config
|
||||||
|
'404':
|
||||||
|
description: No matching connection found
|
||||||
|
/api/v1/teams/{teamId}/connections:
|
||||||
|
get:
|
||||||
|
tags: [Teams, Extensions]
|
||||||
|
summary: List team connections
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
parameters:
|
||||||
|
- $ref: '#/components/parameters/TeamID'
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Team connections
|
||||||
|
post:
|
||||||
|
tags: [Teams, Extensions]
|
||||||
|
summary: Create team connection
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
parameters:
|
||||||
|
- $ref: '#/components/parameters/TeamID'
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
required: [type, package_id, name]
|
||||||
|
properties:
|
||||||
|
type: { type: string }
|
||||||
|
package_id: { type: string }
|
||||||
|
name: { type: string }
|
||||||
|
config: { type: object }
|
||||||
|
responses:
|
||||||
|
'201':
|
||||||
|
description: Created
|
||||||
|
/api/v1/teams/{teamId}/connections/{id}:
|
||||||
|
put:
|
||||||
|
tags: [Teams, Extensions]
|
||||||
|
summary: Update team connection
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
parameters:
|
||||||
|
- $ref: '#/components/parameters/TeamID'
|
||||||
|
- $ref: '#/components/parameters/ResourceID'
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Updated
|
||||||
|
delete:
|
||||||
|
tags: [Teams, Extensions]
|
||||||
|
summary: Delete team connection
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
parameters:
|
||||||
|
- $ref: '#/components/parameters/TeamID'
|
||||||
|
- $ref: '#/components/parameters/ResourceID'
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Deleted
|
||||||
|
/api/v1/admin/connections:
|
||||||
|
get:
|
||||||
|
tags: [Admin, Extensions]
|
||||||
|
summary: List global connections
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Global connections
|
||||||
|
post:
|
||||||
|
tags: [Admin, Extensions]
|
||||||
|
summary: Create global connection
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
required: [type, package_id, name]
|
||||||
|
properties:
|
||||||
|
type: { type: string }
|
||||||
|
package_id: { type: string }
|
||||||
|
name: { type: string }
|
||||||
|
config: { type: object }
|
||||||
|
responses:
|
||||||
|
'201':
|
||||||
|
description: Created
|
||||||
|
/api/v1/admin/connections/{id}:
|
||||||
|
put:
|
||||||
|
tags: [Admin, Extensions]
|
||||||
|
summary: Update global connection
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
parameters:
|
||||||
|
- $ref: '#/components/parameters/ResourceID'
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Updated
|
||||||
|
delete:
|
||||||
|
tags: [Admin, Extensions]
|
||||||
|
summary: Delete global connection
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
parameters:
|
||||||
|
- $ref: '#/components/parameters/ResourceID'
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Deleted
|
||||||
/api/v1/admin/workflows/{id}/export:
|
/api/v1/admin/workflows/{id}/export:
|
||||||
get:
|
get:
|
||||||
tags:
|
tags:
|
||||||
@@ -12059,6 +12266,36 @@ components:
|
|||||||
updated_at:
|
updated_at:
|
||||||
type: string
|
type: string
|
||||||
format: date-time
|
format: date-time
|
||||||
|
ExtConnection:
|
||||||
|
type: object
|
||||||
|
description: "v0.38.1: Extension connection credential (secrets masked in list responses)"
|
||||||
|
properties:
|
||||||
|
id:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
type:
|
||||||
|
type: string
|
||||||
|
description: Connection type identifier (e.g. "gitea", "github")
|
||||||
|
package_id:
|
||||||
|
type: string
|
||||||
|
description: Declaring package ID (UI attribution)
|
||||||
|
scope:
|
||||||
|
type: string
|
||||||
|
enum: [global, team, personal]
|
||||||
|
name:
|
||||||
|
type: string
|
||||||
|
description: User label (e.g. "Work Gitea")
|
||||||
|
is_active:
|
||||||
|
type: boolean
|
||||||
|
has_config:
|
||||||
|
type: boolean
|
||||||
|
description: Whether config fields are set (secrets never exposed in list)
|
||||||
|
created_at:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
|
updated_at:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
Extension:
|
Extension:
|
||||||
type: object
|
type: object
|
||||||
properties:
|
properties:
|
||||||
|
|||||||
@@ -65,6 +65,7 @@ type Stores struct {
|
|||||||
Tickets TicketStore // v0.32.0: WS auth tickets (PG-backed for cross-pod)
|
Tickets TicketStore // v0.32.0: WS auth tickets (PG-backed for cross-pod)
|
||||||
RateLimits RateLimitStore // v0.32.0: Distributed rate limiting
|
RateLimits RateLimitStore // v0.32.0: Distributed rate limiting
|
||||||
Export ExportStore // v0.34.0: Data portability batch reads + GDPR ops
|
Export ExportStore // v0.34.0: Data portability batch reads + GDPR ops
|
||||||
|
Connections ConnectionStore // v0.38.1: Extension connection credentials
|
||||||
}
|
}
|
||||||
|
|
||||||
// =========================================
|
// =========================================
|
||||||
@@ -1366,6 +1367,35 @@ type ExportStore interface {
|
|||||||
|
|
||||||
// =========================================
|
// =========================================
|
||||||
// SHARED TYPES
|
// SHARED TYPES
|
||||||
|
// =========================================
|
||||||
|
// CONNECTION STORE (v0.38.1)
|
||||||
|
// =========================================
|
||||||
|
|
||||||
|
type ConnectionStore interface {
|
||||||
|
Create(ctx context.Context, conn *models.ExtConnection) error
|
||||||
|
GetByID(ctx context.Context, id string) (*models.ExtConnection, error)
|
||||||
|
Update(ctx context.Context, id string, patch models.ExtConnectionPatch) error
|
||||||
|
Delete(ctx context.Context, id string) error
|
||||||
|
|
||||||
|
// Scoped queries
|
||||||
|
ListGlobal(ctx context.Context) ([]models.ExtConnection, error)
|
||||||
|
ListForTeam(ctx context.Context, teamID string) ([]models.ExtConnection, error)
|
||||||
|
ListForUser(ctx context.Context, userID string) ([]models.ExtConnection, error)
|
||||||
|
|
||||||
|
// Resolution chain: personal → team → global.
|
||||||
|
// Returns first active connection matching type (and optional name).
|
||||||
|
// Empty name means "first match". Returns sql.ErrNoRows if not found.
|
||||||
|
Resolve(ctx context.Context, userID, connType, name string) (*models.ExtConnection, error)
|
||||||
|
|
||||||
|
// ResolveAll returns all active connections of the given type accessible
|
||||||
|
// to the user (personal + team + global).
|
||||||
|
ResolveAll(ctx context.Context, userID, connType string) ([]models.ExtConnection, error)
|
||||||
|
|
||||||
|
// DeleteByIDAndScope deletes a connection only if it matches the given
|
||||||
|
// scope and owner. Returns rows affected (0 or 1).
|
||||||
|
DeleteByIDAndScope(ctx context.Context, id, scope, ownerID string) (int64, error)
|
||||||
|
}
|
||||||
|
|
||||||
// =========================================
|
// =========================================
|
||||||
|
|
||||||
// ListOptions provides standard pagination/sort for list queries.
|
// ListOptions provides standard pagination/sort for list queries.
|
||||||
|
|||||||
182
server/store/postgres/ext_connection.go
Normal file
182
server/store/postgres/ext_connection.go
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
package postgres
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"chat-switchboard/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── ConnectionStore (v0.38.1) ────────────────────────
|
||||||
|
|
||||||
|
type ConnectionStore struct{}
|
||||||
|
|
||||||
|
func NewConnectionStore() *ConnectionStore { return &ConnectionStore{} }
|
||||||
|
|
||||||
|
const connCols = `id, type, package_id, scope, owner_id, name, config, is_active, created_at, updated_at`
|
||||||
|
|
||||||
|
func (s *ConnectionStore) Create(ctx context.Context, conn *models.ExtConnection) error {
|
||||||
|
return DB.QueryRowContext(ctx, `
|
||||||
|
INSERT INTO ext_connections (type, package_id, scope, owner_id, name, config, is_active)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||||
|
RETURNING id, created_at, updated_at`,
|
||||||
|
conn.Type, conn.PackageID, conn.Scope, conn.OwnerID,
|
||||||
|
conn.Name, ToJSON(conn.Config), conn.IsActive,
|
||||||
|
).Scan(&conn.ID, &conn.CreatedAt, &conn.UpdatedAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ConnectionStore) GetByID(ctx context.Context, id string) (*models.ExtConnection, error) {
|
||||||
|
row := DB.QueryRowContext(ctx,
|
||||||
|
fmt.Sprintf("SELECT %s FROM ext_connections WHERE id = $1", connCols), id)
|
||||||
|
return scanConnection(row)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ConnectionStore) Update(ctx context.Context, id string, patch models.ExtConnectionPatch) error {
|
||||||
|
b := NewUpdate("ext_connections")
|
||||||
|
if patch.Name != nil {
|
||||||
|
b.Set("name", *patch.Name)
|
||||||
|
}
|
||||||
|
if patch.Config != nil {
|
||||||
|
b.SetJSON("config", patch.Config)
|
||||||
|
}
|
||||||
|
if patch.IsActive != nil {
|
||||||
|
b.Set("is_active", *patch.IsActive)
|
||||||
|
}
|
||||||
|
if !b.HasSets() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
b.Where("id", id)
|
||||||
|
_, err := b.Exec(DB)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ConnectionStore) Delete(ctx context.Context, id string) error {
|
||||||
|
_, err := DB.ExecContext(ctx, "DELETE FROM ext_connections WHERE id = $1", id)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Scoped Queries ─────────────────────────────────
|
||||||
|
|
||||||
|
func (s *ConnectionStore) ListGlobal(ctx context.Context) ([]models.ExtConnection, error) {
|
||||||
|
return s.listByScope(ctx, models.ScopeGlobal, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ConnectionStore) ListForTeam(ctx context.Context, teamID string) ([]models.ExtConnection, error) {
|
||||||
|
return s.listByScope(ctx, models.ScopeTeam, teamID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ConnectionStore) ListForUser(ctx context.Context, userID string) ([]models.ExtConnection, error) {
|
||||||
|
return s.listByScope(ctx, models.ScopePersonal, userID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Resolution ─────────────────────────────────────
|
||||||
|
|
||||||
|
// Resolve returns the first active connection matching type (and optional name)
|
||||||
|
// via the scope chain: personal → team → global.
|
||||||
|
func (s *ConnectionStore) Resolve(ctx context.Context, userID, connType, name string) (*models.ExtConnection, error) {
|
||||||
|
q := fmt.Sprintf(`
|
||||||
|
SELECT %s FROM ext_connections
|
||||||
|
WHERE type = $1 AND is_active = true
|
||||||
|
AND (
|
||||||
|
(scope = 'personal' AND owner_id = $2)
|
||||||
|
OR (scope = 'team' AND owner_id IN (
|
||||||
|
SELECT team_id FROM team_members WHERE user_id = $2
|
||||||
|
))
|
||||||
|
OR (scope = 'global')
|
||||||
|
)`, connCols)
|
||||||
|
args := []any{connType, userID}
|
||||||
|
if name != "" {
|
||||||
|
q += " AND name = $3"
|
||||||
|
args = append(args, name)
|
||||||
|
}
|
||||||
|
// Priority: personal(0) → team(1) → global(2).
|
||||||
|
q += " ORDER BY CASE scope WHEN 'personal' THEN 0 WHEN 'team' THEN 1 ELSE 2 END, created_at ASC LIMIT 1"
|
||||||
|
row := DB.QueryRowContext(ctx, q, args...)
|
||||||
|
return scanConnection(row)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResolveAll returns all active connections of the given type accessible to the user.
|
||||||
|
func (s *ConnectionStore) ResolveAll(ctx context.Context, userID, connType string) ([]models.ExtConnection, error) {
|
||||||
|
rows, err := DB.QueryContext(ctx, fmt.Sprintf(`
|
||||||
|
SELECT %s FROM ext_connections
|
||||||
|
WHERE type = $1 AND is_active = true
|
||||||
|
AND (
|
||||||
|
(scope = 'personal' AND owner_id = $2)
|
||||||
|
OR (scope = 'team' AND owner_id IN (
|
||||||
|
SELECT team_id FROM team_members WHERE user_id = $2
|
||||||
|
))
|
||||||
|
OR (scope = 'global')
|
||||||
|
)
|
||||||
|
ORDER BY scope ASC, name ASC`, connCols), connType, userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
return scanConnections(rows)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteByIDAndScope deletes a connection only if it matches the scope/owner.
|
||||||
|
func (s *ConnectionStore) DeleteByIDAndScope(ctx context.Context, id, scope, ownerID string) (int64, error) {
|
||||||
|
res, err := DB.ExecContext(ctx,
|
||||||
|
`DELETE FROM ext_connections WHERE id = $1 AND scope = $2 AND owner_id = $3`,
|
||||||
|
id, scope, ownerID)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return res.RowsAffected()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Internal ───────────────────────────────────────
|
||||||
|
|
||||||
|
func (s *ConnectionStore) listByScope(ctx context.Context, scope, ownerID string) ([]models.ExtConnection, error) {
|
||||||
|
var rows *sql.Rows
|
||||||
|
var err error
|
||||||
|
if scope == models.ScopeGlobal {
|
||||||
|
rows, err = DB.QueryContext(ctx,
|
||||||
|
fmt.Sprintf("SELECT %s FROM ext_connections WHERE scope = $1 ORDER BY type, name", connCols),
|
||||||
|
scope)
|
||||||
|
} else {
|
||||||
|
rows, err = DB.QueryContext(ctx,
|
||||||
|
fmt.Sprintf("SELECT %s FROM ext_connections WHERE scope = $1 AND owner_id = $2 ORDER BY type, name", connCols),
|
||||||
|
scope, ownerID)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
return scanConnections(rows)
|
||||||
|
}
|
||||||
|
|
||||||
|
func scanConnection(row *sql.Row) (*models.ExtConnection, error) {
|
||||||
|
var c models.ExtConnection
|
||||||
|
var configJSON []byte
|
||||||
|
err := row.Scan(
|
||||||
|
&c.ID, &c.Type, &c.PackageID, &c.Scope, &c.OwnerID,
|
||||||
|
&c.Name, &configJSON, &c.IsActive, &c.CreatedAt, &c.UpdatedAt,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
json.Unmarshal(configJSON, &c.Config)
|
||||||
|
return &c, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func scanConnections(rows *sql.Rows) ([]models.ExtConnection, error) {
|
||||||
|
var result []models.ExtConnection
|
||||||
|
for rows.Next() {
|
||||||
|
var c models.ExtConnection
|
||||||
|
var configJSON []byte
|
||||||
|
err := rows.Scan(
|
||||||
|
&c.ID, &c.Type, &c.PackageID, &c.Scope, &c.OwnerID,
|
||||||
|
&c.Name, &configJSON, &c.IsActive, &c.CreatedAt, &c.UpdatedAt,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
json.Unmarshal(configJSON, &c.Config)
|
||||||
|
result = append(result, c)
|
||||||
|
}
|
||||||
|
return result, rows.Err()
|
||||||
|
}
|
||||||
@@ -51,5 +51,6 @@ func NewStores(db *sql.DB) store.Stores {
|
|||||||
Tickets: NewTicketStore(),
|
Tickets: NewTicketStore(),
|
||||||
RateLimits: NewRateLimitStore(),
|
RateLimits: NewRateLimitStore(),
|
||||||
Export: NewExportStore(),
|
Export: NewExportStore(),
|
||||||
|
Connections: NewConnectionStore(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
186
server/store/sqlite/ext_connection.go
Normal file
186
server/store/sqlite/ext_connection.go
Normal file
@@ -0,0 +1,186 @@
|
|||||||
|
package sqlite
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"chat-switchboard/models"
|
||||||
|
"chat-switchboard/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── ConnectionStore (v0.38.1) ────────────────────────
|
||||||
|
|
||||||
|
type ConnectionStore struct{}
|
||||||
|
|
||||||
|
func NewConnectionStore() *ConnectionStore { return &ConnectionStore{} }
|
||||||
|
|
||||||
|
const connCols = `id, type, package_id, scope, owner_id, name, config, is_active, created_at, updated_at`
|
||||||
|
|
||||||
|
func (s *ConnectionStore) Create(ctx context.Context, conn *models.ExtConnection) error {
|
||||||
|
conn.ID = store.NewID()
|
||||||
|
now := time.Now().UTC()
|
||||||
|
conn.CreatedAt = now
|
||||||
|
conn.UpdatedAt = now
|
||||||
|
_, err := DB.ExecContext(ctx, `
|
||||||
|
INSERT INTO ext_connections (id, type, package_id, scope, owner_id, name, config, is_active,
|
||||||
|
created_at, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
conn.ID, conn.Type, conn.PackageID, conn.Scope, conn.OwnerID,
|
||||||
|
conn.Name, ToJSON(conn.Config), conn.IsActive,
|
||||||
|
now.Format(timeFmt), now.Format(timeFmt),
|
||||||
|
)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ConnectionStore) GetByID(ctx context.Context, id string) (*models.ExtConnection, error) {
|
||||||
|
row := DB.QueryRowContext(ctx,
|
||||||
|
fmt.Sprintf("SELECT %s FROM ext_connections WHERE id = ?", connCols), id)
|
||||||
|
return scanConnection(row)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ConnectionStore) Update(ctx context.Context, id string, patch models.ExtConnectionPatch) error {
|
||||||
|
b := NewUpdate("ext_connections")
|
||||||
|
if patch.Name != nil {
|
||||||
|
b.Set("name", *patch.Name)
|
||||||
|
}
|
||||||
|
if patch.Config != nil {
|
||||||
|
b.SetJSON("config", patch.Config)
|
||||||
|
}
|
||||||
|
if patch.IsActive != nil {
|
||||||
|
b.Set("is_active", *patch.IsActive)
|
||||||
|
}
|
||||||
|
if !b.HasSets() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
b.Where("id", id)
|
||||||
|
_, err := b.Exec(DB)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ConnectionStore) Delete(ctx context.Context, id string) error {
|
||||||
|
_, err := DB.ExecContext(ctx, "DELETE FROM ext_connections WHERE id = ?", id)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Scoped Queries ─────────────────────────────────
|
||||||
|
|
||||||
|
func (s *ConnectionStore) ListGlobal(ctx context.Context) ([]models.ExtConnection, error) {
|
||||||
|
return s.listByScope(ctx, models.ScopeGlobal, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ConnectionStore) ListForTeam(ctx context.Context, teamID string) ([]models.ExtConnection, error) {
|
||||||
|
return s.listByScope(ctx, models.ScopeTeam, teamID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ConnectionStore) ListForUser(ctx context.Context, userID string) ([]models.ExtConnection, error) {
|
||||||
|
return s.listByScope(ctx, models.ScopePersonal, userID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Resolution ─────────────────────────────────────
|
||||||
|
|
||||||
|
func (s *ConnectionStore) Resolve(ctx context.Context, userID, connType, name string) (*models.ExtConnection, error) {
|
||||||
|
q := fmt.Sprintf(`
|
||||||
|
SELECT %s FROM ext_connections
|
||||||
|
WHERE type = ? AND is_active = 1
|
||||||
|
AND (
|
||||||
|
(scope = 'personal' AND owner_id = ?)
|
||||||
|
OR (scope = 'team' AND owner_id IN (
|
||||||
|
SELECT team_id FROM team_members WHERE user_id = ?
|
||||||
|
))
|
||||||
|
OR (scope = 'global')
|
||||||
|
)`, connCols)
|
||||||
|
args := []any{connType, userID, userID}
|
||||||
|
if name != "" {
|
||||||
|
q += " AND name = ?"
|
||||||
|
args = append(args, name)
|
||||||
|
}
|
||||||
|
// Priority: personal(0) → team(1) → global(2).
|
||||||
|
q += " ORDER BY CASE scope WHEN 'personal' THEN 0 WHEN 'team' THEN 1 ELSE 2 END, created_at ASC LIMIT 1"
|
||||||
|
row := DB.QueryRowContext(ctx, q, args...)
|
||||||
|
return scanConnection(row)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ConnectionStore) ResolveAll(ctx context.Context, userID, connType string) ([]models.ExtConnection, error) {
|
||||||
|
rows, err := DB.QueryContext(ctx, fmt.Sprintf(`
|
||||||
|
SELECT %s FROM ext_connections
|
||||||
|
WHERE type = ? AND is_active = 1
|
||||||
|
AND (
|
||||||
|
(scope = 'personal' AND owner_id = ?)
|
||||||
|
OR (scope = 'team' AND owner_id IN (
|
||||||
|
SELECT team_id FROM team_members WHERE user_id = ?
|
||||||
|
))
|
||||||
|
OR (scope = 'global')
|
||||||
|
)
|
||||||
|
ORDER BY scope ASC, name ASC`, connCols), connType, userID, userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
return scanConnections(rows)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ConnectionStore) DeleteByIDAndScope(ctx context.Context, id, scope, ownerID string) (int64, error) {
|
||||||
|
res, err := DB.ExecContext(ctx,
|
||||||
|
`DELETE FROM ext_connections WHERE id = ? AND scope = ? AND owner_id = ?`,
|
||||||
|
id, scope, ownerID)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return res.RowsAffected()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Internal ───────────────────────────────────────
|
||||||
|
|
||||||
|
func (s *ConnectionStore) listByScope(ctx context.Context, scope, ownerID string) ([]models.ExtConnection, error) {
|
||||||
|
var rows *sql.Rows
|
||||||
|
var err error
|
||||||
|
if scope == models.ScopeGlobal {
|
||||||
|
rows, err = DB.QueryContext(ctx,
|
||||||
|
fmt.Sprintf("SELECT %s FROM ext_connections WHERE scope = ? ORDER BY type, name", connCols),
|
||||||
|
scope)
|
||||||
|
} else {
|
||||||
|
rows, err = DB.QueryContext(ctx,
|
||||||
|
fmt.Sprintf("SELECT %s FROM ext_connections WHERE scope = ? AND owner_id = ? ORDER BY type, name", connCols),
|
||||||
|
scope, ownerID)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
return scanConnections(rows)
|
||||||
|
}
|
||||||
|
|
||||||
|
func scanConnection(row *sql.Row) (*models.ExtConnection, error) {
|
||||||
|
var c models.ExtConnection
|
||||||
|
var configJSON []byte
|
||||||
|
err := row.Scan(
|
||||||
|
&c.ID, &c.Type, &c.PackageID, &c.Scope, &c.OwnerID,
|
||||||
|
&c.Name, &configJSON, &c.IsActive, st(&c.CreatedAt), st(&c.UpdatedAt),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
json.Unmarshal(configJSON, &c.Config)
|
||||||
|
return &c, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func scanConnections(rows *sql.Rows) ([]models.ExtConnection, error) {
|
||||||
|
var result []models.ExtConnection
|
||||||
|
for rows.Next() {
|
||||||
|
var c models.ExtConnection
|
||||||
|
var configJSON []byte
|
||||||
|
err := rows.Scan(
|
||||||
|
&c.ID, &c.Type, &c.PackageID, &c.Scope, &c.OwnerID,
|
||||||
|
&c.Name, &configJSON, &c.IsActive, st(&c.CreatedAt), st(&c.UpdatedAt),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
json.Unmarshal(configJSON, &c.Config)
|
||||||
|
result = append(result, c)
|
||||||
|
}
|
||||||
|
return result, rows.Err()
|
||||||
|
}
|
||||||
@@ -51,5 +51,6 @@ func NewStores(db *sql.DB) store.Stores {
|
|||||||
Tickets: NewTicketStore(),
|
Tickets: NewTicketStore(),
|
||||||
RateLimits: NewRateLimitStore(),
|
RateLimits: NewRateLimitStore(),
|
||||||
Export: NewExportStore(),
|
Export: NewExportStore(),
|
||||||
|
Connections: NewConnectionStore(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -188,6 +188,12 @@ export function createDomains(restClient) {
|
|||||||
fetchModels: (id) => rc.post(`/api/v1/api-configs/${id}/models/fetch`),
|
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 ──────────────────
|
// ── 11. Notifications ──────────────────
|
||||||
notifications: {
|
notifications: {
|
||||||
list: (opts) => rc.get('/api/v1/notifications' + _qs(opts)),
|
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),
|
updateProvider:(id, pid, data) => rc.put(`/api/v1/teams/${id}/providers/${pid}`, data),
|
||||||
deleteProvider:(id, pid) => rc.del(`/api/v1/teams/${id}/providers/${pid}`),
|
deleteProvider:(id, pid) => rc.del(`/api/v1/teams/${id}/providers/${pid}`),
|
||||||
providerModels:(id, pid) => rc.get(`/api/v1/teams/${id}/providers/${pid}/models`),
|
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`),
|
roles: (id) => rc.get(`/api/v1/teams/${id}/roles`),
|
||||||
updateRole: (id, role, config) => rc.put(`/api/v1/teams/${id}/roles/${role}`, config),
|
updateRole: (id, role, config) => rc.put(`/api/v1/teams/${id}/roles/${role}`, config),
|
||||||
deleteRole: (id, role) => rc.del(`/api/v1/teams/${id}/roles/${role}`),
|
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}`),
|
del: (id) => rc.del(`/api/v1/admin/extensions/${id}`),
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// v0.38.1: Global connections
|
||||||
|
connections: crud(rc, '/api/v1/admin/connections'),
|
||||||
|
|
||||||
packages: {
|
packages: {
|
||||||
list: (opts) => rc.get('/api/v1/admin/packages' + _qs(opts)),
|
list: (opts) => rc.get('/api/v1/admin/packages' + _qs(opts)),
|
||||||
get: (id) => rc.get(`/api/v1/admin/packages/${id}`),
|
get: (id) => rc.get(`/api/v1/admin/packages/${id}`),
|
||||||
|
|||||||
@@ -152,7 +152,7 @@ export async function boot() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Marker for idempotency
|
// Marker for idempotency
|
||||||
sw._sdk = '0.38.0';
|
sw._sdk = '0.38.1';
|
||||||
|
|
||||||
// 8. Expose globally
|
// 8. Expose globally
|
||||||
window.sw = sw;
|
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'],
|
ai: ['providers', 'models', 'personas', 'roles', 'knowledgeBases', 'memory'],
|
||||||
workflows: ['workflows', 'tasks'],
|
workflows: ['workflows', 'tasks'],
|
||||||
routing: ['health', 'routing', 'capabilities'],
|
routing: ['health', 'routing', 'capabilities'],
|
||||||
system: ['settings', 'storage', 'packages', 'channels', 'broadcast'],
|
system: ['settings', 'storage', 'packages', 'connections', 'channels', 'broadcast'],
|
||||||
monitoring: ['dashboard', 'usage', 'audit', 'stats'],
|
monitoring: ['dashboard', 'usage', 'audit', 'stats'],
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -32,6 +32,7 @@ const ADMIN_LABELS = {
|
|||||||
health: 'Health', routing: 'Routing', capabilities: 'Capabilities',
|
health: 'Health', routing: 'Routing', capabilities: 'Capabilities',
|
||||||
workflows: 'Workflows', tasks: 'Tasks',
|
workflows: 'Workflows', tasks: 'Tasks',
|
||||||
settings: 'Settings', storage: 'Storage', packages: 'Packages',
|
settings: 'Settings', storage: 'Storage', packages: 'Packages',
|
||||||
|
connections: 'Connections',
|
||||||
channels: 'Channels', broadcast: 'Broadcast',
|
channels: 'Channels', broadcast: 'Broadcast',
|
||||||
dashboard: 'Dashboard', usage: 'Usage', audit: 'Audit', stats: 'Stats',
|
dashboard: 'Dashboard', usage: 'Usage', audit: 'Audit', stats: 'Stats',
|
||||||
};
|
};
|
||||||
@@ -66,6 +67,7 @@ const sectionModules = {
|
|||||||
settings: () => import(`./settings.js${_v}`),
|
settings: () => import(`./settings.js${_v}`),
|
||||||
storage: () => import(`./storage.js${_v}`),
|
storage: () => import(`./storage.js${_v}`),
|
||||||
packages: () => import(`./packages.js${_v}`),
|
packages: () => import(`./packages.js${_v}`),
|
||||||
|
connections: () => import(`./connections.js${_v}`),
|
||||||
channels: () => import(`./channels.js${_v}`),
|
channels: () => import(`./channels.js${_v}`),
|
||||||
broadcast: () => import(`./broadcast.js${_v}`),
|
broadcast: () => import(`./broadcast.js${_v}`),
|
||||||
dashboard: () => import(`./dashboard.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'),
|
teams: () => import('./teams.js'),
|
||||||
models: () => import('./models.js'),
|
models: () => import('./models.js'),
|
||||||
providers: () => import('./providers.js'),
|
providers: () => import('./providers.js'),
|
||||||
|
connections: () => import('./connections.js'),
|
||||||
personas: () => import('./personas.js'),
|
personas: () => import('./personas.js'),
|
||||||
roles: () => import('./roles.js'),
|
roles: () => import('./roles.js'),
|
||||||
usage: () => import('./usage.js'),
|
usage: () => import('./usage.js'),
|
||||||
@@ -49,6 +50,7 @@ const NAV_ITEMS = [
|
|||||||
{ key: 'teams', label: 'Teams' },
|
{ key: 'teams', label: 'Teams' },
|
||||||
{ key: 'workflows', label: 'Assignments' },
|
{ key: 'workflows', label: 'Assignments' },
|
||||||
{ key: 'tasks', label: 'Tasks' },
|
{ key: 'tasks', label: 'Tasks' },
|
||||||
|
{ key: 'connections', label: 'Connections' },
|
||||||
{ key: 'gitkeys', label: 'Git Keys' },
|
{ key: 'gitkeys', label: 'Git Keys' },
|
||||||
{ key: 'knowledge', label: 'Knowledge Bases' },
|
{ key: 'knowledge', label: 'Knowledge Bases' },
|
||||||
{ key: 'memory', label: 'Memory' },
|
{ key: 'memory', label: 'Memory' },
|
||||||
@@ -67,6 +69,7 @@ const SECTION_TITLES = {
|
|||||||
general: 'General', appearance: 'Appearance', models: 'Models',
|
general: 'General', appearance: 'Appearance', models: 'Models',
|
||||||
personas: 'Personas', profile: 'Profile', teams: 'Teams',
|
personas: 'Personas', profile: 'Profile', teams: 'Teams',
|
||||||
workflows: 'Assignments', tasks: 'Tasks', gitkeys: 'Git Keys',
|
workflows: 'Assignments', tasks: 'Tasks', gitkeys: 'Git Keys',
|
||||||
|
connections: 'Connections',
|
||||||
data: 'Data & Privacy', providers: 'My Providers', roles: 'Model Roles',
|
data: 'Data & Privacy', providers: 'My Providers', roles: 'Model Roles',
|
||||||
usage: 'My Usage', knowledge: 'Knowledge Bases', memory: 'Memory',
|
usage: 'My Usage', knowledge: 'Knowledge Bases', memory: 'Memory',
|
||||||
notifications: 'Notifications',
|
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 = [
|
const SECTIONS = [
|
||||||
{ key: 'members', label: 'Members' },
|
{ key: 'members', label: 'Members' },
|
||||||
{ key: 'providers', label: 'Providers' },
|
{ key: 'providers', label: 'Providers' },
|
||||||
|
{ key: 'connections', label: 'Connections' },
|
||||||
{ key: 'personas', label: 'Personas' },
|
{ key: 'personas', label: 'Personas' },
|
||||||
{ key: 'knowledge', label: 'Knowledge' },
|
{ key: 'knowledge', label: 'Knowledge' },
|
||||||
{ key: 'groups', label: 'Groups' },
|
{ key: 'groups', label: 'Groups' },
|
||||||
@@ -32,7 +33,8 @@ const SECTIONS = [
|
|||||||
// ── Lazy section imports ────────────────────
|
// ── Lazy section imports ────────────────────
|
||||||
const sectionModules = {
|
const sectionModules = {
|
||||||
members: () => import('./members.js'),
|
members: () => import('./members.js'),
|
||||||
providers: () => import('./providers.js'),
|
providers: () => import('./providers.js'),
|
||||||
|
connections: () => import('./connections.js'),
|
||||||
personas: () => import('./personas.js'),
|
personas: () => import('./personas.js'),
|
||||||
knowledge: () => import('./knowledge.js'),
|
knowledge: () => import('./knowledge.js'),
|
||||||
groups: () => import('./groups.js'),
|
groups: () => import('./groups.js'),
|
||||||
|
|||||||
Reference in New Issue
Block a user