Compare commits
1 Commits
v0.6.7
...
d2739aabd2
| Author | SHA1 | Date | |
|---|---|---|---|
| d2739aabd2 |
29
CHANGELOG.md
29
CHANGELOG.md
@@ -2,6 +2,35 @@
|
||||
|
||||
All notable changes to Switchboard Core are documented here.
|
||||
|
||||
## v0.2.8 — Team Admin Settings Audit (Pass 1)
|
||||
|
||||
### Removed
|
||||
|
||||
- **Dead `HasPrivateProviderRequirement`**: Store method checked team settings
|
||||
for `require_private_providers` (BYOK vestige). Removed from interface,
|
||||
PostgreSQL, and SQLite implementations. No callers existed.
|
||||
- **Dead `UserRole` field on `TeamMember`**: Joined `users.role` column
|
||||
(deprecated in v0.2.0 RBAC migration). Removed from model, `ListMembers`
|
||||
and `GetMember` queries in both stores. Frontend never consumed it.
|
||||
- **Dead `allow_team_providers` policy**: Removed from `PolicyDefaults`, both
|
||||
test seed data blocks, and ICD test assertions. No handler or UI read it.
|
||||
- **Dead personas in workflow stage UI**: Removed `sw.api.teams.personas()`
|
||||
call (endpoint doesn't exist), personas state, persona dropdown in
|
||||
StageForm, and persona badge in stage list.
|
||||
- **Dead `history_mode` in stage UI**: Removed history mode selector and state
|
||||
from StageForm. Backend column retained for v0.3.x schema migration.
|
||||
- **Dead ICD tests**: Removed assertions for `/teams/:teamId/personas`,
|
||||
`/teams/:teamId/providers`, and `/teams/:teamId/models` — endpoints were
|
||||
removed in Phase 0 fork.
|
||||
- **Stale `chat_only` stage mode**: Frontend `STAGE_MODES` updated from
|
||||
`['chat_only', 'form_only', 'form_chat', 'review']` to
|
||||
`['form_only', 'form_chat', 'review', 'custom']` matching the backend
|
||||
CHECK constraint.
|
||||
- **Stale comments**: Removed references to deleted `team_providers.go`,
|
||||
`personas.go`, and `apiconfigs.go` files.
|
||||
|
||||
---
|
||||
|
||||
## v0.2.7 — User Settings Audit
|
||||
|
||||
### Changed
|
||||
|
||||
@@ -107,8 +107,8 @@ SDK stabilization, and the first rebuilt extension (tasks).
|
||||
|
||||
| Step | Status | Description |
|
||||
|------|--------|-------------|
|
||||
| Team admin E2E | ⬚ | Verify team member management, team settings cascade, role assignment (admin/member). Audit for dead code from pre-fork team features. First pass — validates current functionality before v0.3.x adds team roles. |
|
||||
| User settings team tab | ⬚ | Verify the Teams section in user settings — team list, join/leave, team-scoped settings. |
|
||||
| Team admin E2E | ✅ | Audited team member management, settings cascade, role assignment. Removed dead code: `HasPrivateProviderRequirement` (BYOK vestige), `UserRole` on `TeamMember` (deprecated role column), `allow_team_providers` policy default, dead personas/providers/models ICD tests. |
|
||||
| Workflow stage UI cleanup | ✅ | Removed dead personas dropdown, `history_mode` selector, stale `chat_only` mode. Updated `STAGE_MODES` to match backend CHECK constraint (`form_only`, `form_chat`, `review`, `custom`). Removed stale comments referencing deleted files. |
|
||||
|
||||
### v0.2.9 — Builtin Extension Retirement
|
||||
|
||||
|
||||
@@ -74,24 +74,6 @@
|
||||
T.assert(Array.isArray(d.data), 'data should be array');
|
||||
});
|
||||
|
||||
await T.test('crud', 'teams', 'GET /teams/:teamId/personas', async function () {
|
||||
var d = await T.apiGet('/teams/' + teamId + '/personas');
|
||||
T.assertHasKey(d, 'data', '/team-personas');
|
||||
T.assert(Array.isArray(d.data), 'team personas should be array');
|
||||
});
|
||||
|
||||
await T.test('crud', 'teams', 'GET /teams/:teamId/providers', async function () {
|
||||
var d = await T.apiGet('/teams/' + teamId + '/providers');
|
||||
T.assertHasKey(d, 'data', '/team-providers');
|
||||
T.assert(Array.isArray(d.data), 'data should be array');
|
||||
T.assertHasKey(d, 'allow_team_providers', '/team-providers');
|
||||
});
|
||||
|
||||
await T.test('crud', 'teams', 'GET /teams/:teamId/models', async function () {
|
||||
var d = await T.apiGet('/teams/' + teamId + '/models');
|
||||
T.assertHasKey(d, 'models', '/team-models');
|
||||
});
|
||||
|
||||
// ── Team Tasks CRUD ──
|
||||
var teamTaskId = null;
|
||||
|
||||
|
||||
@@ -341,8 +341,7 @@ func TruncateAll(t *testing.T) {
|
||||
DB.Exec(`
|
||||
INSERT INTO platform_policies (key, value) VALUES
|
||||
('allow_registration', 'true'),
|
||||
('default_user_active', 'false'),
|
||||
('allow_team_providers', 'true')
|
||||
('default_user_active', 'false')
|
||||
ON CONFLICT (key) DO NOTHING
|
||||
`)
|
||||
} else {
|
||||
@@ -356,8 +355,7 @@ func TruncateAll(t *testing.T) {
|
||||
DB.Exec(`
|
||||
INSERT INTO platform_policies (key, value) VALUES
|
||||
('allow_registration', 'true'),
|
||||
('default_user_active', 'false'),
|
||||
('allow_team_providers', 'true')
|
||||
('default_user_active', 'false')
|
||||
ON CONFLICT (key) DO NOTHING
|
||||
`)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
package handlers
|
||||
|
||||
// v0.38.1: Extension connection handlers — team scope.
|
||||
// Methods on TeamHandler, mirrors team_providers.go.
|
||||
// Extension connection handlers — team scope.
|
||||
// Methods on TeamHandler.
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
@@ -78,15 +78,13 @@ type TeamMember struct {
|
||||
Email string `json:"email,omitempty"`
|
||||
DisplayName string `json:"display_name,omitempty"`
|
||||
Username string `json:"username,omitempty"`
|
||||
UserRole string `json:"user_role,omitempty"`
|
||||
}
|
||||
|
||||
// PLATFORM POLICIES
|
||||
|
||||
var PolicyDefaults = map[string]string{
|
||||
"allow_registration": "true",
|
||||
"default_user_active": "false",
|
||||
"allow_team_providers": "true",
|
||||
"allow_registration": "true",
|
||||
"default_user_active": "false",
|
||||
}
|
||||
|
||||
// HandleFromName generates a URL-safe @mention handle from a display name.
|
||||
|
||||
@@ -170,10 +170,6 @@ type TeamStore interface {
|
||||
// AddMemberReturningID inserts a team member and returns the row ID.
|
||||
AddMemberReturningID(ctx context.Context, teamID, userID, role string) (string, error)
|
||||
|
||||
// HasPrivateProviderRequirement checks if a user belongs to any active team
|
||||
// with the require_private_providers setting enabled.
|
||||
HasPrivateProviderRequirement(ctx context.Context, userID string) (bool, error)
|
||||
|
||||
// MergeSettings merges a JSON string into the team's settings column.
|
||||
MergeSettings(ctx context.Context, teamID, settingsJSON string) error
|
||||
}
|
||||
|
||||
@@ -19,8 +19,7 @@ func SetDB(db *sql.DB) {
|
||||
}
|
||||
|
||||
// ── Dynamic SQL Builder ─────────────────────
|
||||
// Replaces the copy-pasted addClause/addField pattern
|
||||
// found in admin.go, personas.go, team_providers.go, apiconfigs.go.
|
||||
// Replaces the copy-pasted addClause/addField pattern.
|
||||
|
||||
// UpdateBuilder constructs a dynamic UPDATE statement.
|
||||
type UpdateBuilder struct {
|
||||
|
||||
@@ -142,7 +142,7 @@ func (s *TeamStore) UpdateMemberRole(ctx context.Context, teamID, userID, role s
|
||||
func (s *TeamStore) ListMembers(ctx context.Context, teamID string) ([]models.TeamMember, error) {
|
||||
rows, err := DB.QueryContext(ctx, `
|
||||
SELECT tm.id, tm.team_id, tm.user_id, tm.role, tm.joined_at,
|
||||
u.email, COALESCE(u.display_name, ''), u.username, u.role as user_role
|
||||
u.email, COALESCE(u.display_name, ''), u.username
|
||||
FROM team_members tm
|
||||
JOIN users u ON u.id = tm.user_id
|
||||
WHERE tm.team_id = $1
|
||||
@@ -156,7 +156,7 @@ func (s *TeamStore) ListMembers(ctx context.Context, teamID string) ([]models.Te
|
||||
for rows.Next() {
|
||||
var m models.TeamMember
|
||||
err := rows.Scan(&m.ID, &m.TeamID, &m.UserID, &m.Role, &m.JoinedAt,
|
||||
&m.Email, &m.DisplayName, &m.Username, &m.UserRole)
|
||||
&m.Email, &m.DisplayName, &m.Username)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -169,12 +169,12 @@ func (s *TeamStore) GetMember(ctx context.Context, teamID, userID string) (*mode
|
||||
var m models.TeamMember
|
||||
err := DB.QueryRowContext(ctx, `
|
||||
SELECT tm.id, tm.team_id, tm.user_id, tm.role, tm.joined_at,
|
||||
u.email, COALESCE(u.display_name, ''), u.username, u.role as user_role
|
||||
u.email, COALESCE(u.display_name, ''), u.username
|
||||
FROM team_members tm
|
||||
JOIN users u ON u.id = tm.user_id
|
||||
WHERE tm.team_id = $1 AND tm.user_id = $2`, teamID, userID).Scan(
|
||||
&m.ID, &m.TeamID, &m.UserID, &m.Role, &m.JoinedAt,
|
||||
&m.Email, &m.DisplayName, &m.Username, &m.UserRole)
|
||||
&m.Email, &m.DisplayName, &m.Username)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -302,19 +302,6 @@ func (s *TeamStore) AddMemberReturningID(ctx context.Context, teamID, userID, ro
|
||||
return id, err
|
||||
}
|
||||
|
||||
func (s *TeamStore) HasPrivateProviderRequirement(ctx context.Context, userID string) (bool, error) {
|
||||
var has bool
|
||||
err := DB.QueryRowContext(ctx, `
|
||||
SELECT EXISTS(
|
||||
SELECT 1 FROM team_members tm
|
||||
JOIN teams t ON t.id = tm.team_id
|
||||
WHERE tm.user_id = $1
|
||||
AND t.is_active = true
|
||||
AND t.settings->>'require_private_providers' = 'true'
|
||||
)`, userID).Scan(&has)
|
||||
return has, err
|
||||
}
|
||||
|
||||
func (s *TeamStore) MergeSettings(ctx context.Context, teamID, settingsJSON string) error {
|
||||
_, err := DB.ExecContext(ctx,
|
||||
`UPDATE teams SET settings = COALESCE(settings, '{}'::jsonb) || $1::jsonb WHERE id = $2`,
|
||||
|
||||
@@ -149,7 +149,7 @@ func (s *TeamStore) UpdateMemberRole(ctx context.Context, teamID, userID, role s
|
||||
func (s *TeamStore) ListMembers(ctx context.Context, teamID string) ([]models.TeamMember, error) {
|
||||
rows, err := DB.QueryContext(ctx, `
|
||||
SELECT tm.id, tm.team_id, tm.user_id, tm.role, tm.joined_at,
|
||||
u.email, COALESCE(u.display_name, ''), u.username, u.role as user_role
|
||||
u.email, COALESCE(u.display_name, ''), u.username
|
||||
FROM team_members tm
|
||||
JOIN users u ON u.id = tm.user_id
|
||||
WHERE tm.team_id = ?
|
||||
@@ -163,7 +163,7 @@ func (s *TeamStore) ListMembers(ctx context.Context, teamID string) ([]models.Te
|
||||
for rows.Next() {
|
||||
var m models.TeamMember
|
||||
err := rows.Scan(&m.ID, &m.TeamID, &m.UserID, &m.Role, &m.JoinedAt,
|
||||
&m.Email, &m.DisplayName, &m.Username, &m.UserRole)
|
||||
&m.Email, &m.DisplayName, &m.Username)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -176,12 +176,12 @@ func (s *TeamStore) GetMember(ctx context.Context, teamID, userID string) (*mode
|
||||
var m models.TeamMember
|
||||
err := DB.QueryRowContext(ctx, `
|
||||
SELECT tm.id, tm.team_id, tm.user_id, tm.role, tm.joined_at,
|
||||
u.email, COALESCE(u.display_name, ''), u.username, u.role as user_role
|
||||
u.email, COALESCE(u.display_name, ''), u.username
|
||||
FROM team_members tm
|
||||
JOIN users u ON u.id = tm.user_id
|
||||
WHERE tm.team_id = ? AND tm.user_id = ?`, teamID, userID).Scan(
|
||||
&m.ID, &m.TeamID, &m.UserID, &m.Role, &m.JoinedAt,
|
||||
&m.Email, &m.DisplayName, &m.Username, &m.UserRole)
|
||||
&m.Email, &m.DisplayName, &m.Username)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -308,19 +308,6 @@ func (s *TeamStore) AddMemberReturningID(ctx context.Context, teamID, userID, ro
|
||||
return id, err
|
||||
}
|
||||
|
||||
func (s *TeamStore) HasPrivateProviderRequirement(ctx context.Context, userID string) (bool, error) {
|
||||
var has bool
|
||||
err := DB.QueryRowContext(ctx, `
|
||||
SELECT EXISTS(
|
||||
SELECT 1 FROM team_members tm
|
||||
JOIN teams t ON t.id = tm.team_id
|
||||
WHERE tm.user_id = ?
|
||||
AND t.is_active = 1
|
||||
AND json_extract(t.settings, '$.require_private_providers') = 'true'
|
||||
)`, userID).Scan(&has)
|
||||
return has, err
|
||||
}
|
||||
|
||||
func (s *TeamStore) MergeSettings(ctx context.Context, teamID, settingsJSON string) error {
|
||||
_, err := DB.ExecContext(ctx,
|
||||
`UPDATE teams SET settings = json_patch(COALESCE(settings, '{}'), ?) WHERE id = ?`,
|
||||
|
||||
@@ -10,8 +10,7 @@ const { html } = window;
|
||||
const { useState, useEffect, useCallback } = hooks;
|
||||
|
||||
const ENTRY_MODES = ['public_link', 'team_only'];
|
||||
const STAGE_MODES = ['chat_only', 'form_only', 'form_chat', 'review'];
|
||||
const HISTORY_MODES = ['full', 'summary', 'fresh'];
|
||||
const STAGE_MODES = ['form_only', 'form_chat', 'review', 'custom'];
|
||||
const TABS = ['Workflows', 'Assignments', 'Monitor'];
|
||||
|
||||
function _timeAgo(ts) {
|
||||
@@ -143,18 +142,13 @@ function WorkflowsTab({ teamId }) {
|
||||
|
||||
function WorkflowEditor({ teamId, workflow, onBack }) {
|
||||
const [stages, setStages] = useState([]);
|
||||
const [personas, setPersonas] = useState([]);
|
||||
const [teams, setTeams] = useState([]);
|
||||
const [editingStage, setEditingStage] = useState(null); // null | 'new' | stage_id
|
||||
|
||||
const loadStages = useCallback(async () => {
|
||||
try {
|
||||
const [s, p] = await Promise.all([
|
||||
sw.api.teams.workflowStages(teamId, workflow.id),
|
||||
sw.api.teams.personas(teamId),
|
||||
]);
|
||||
const s = await sw.api.teams.workflowStages(teamId, workflow.id);
|
||||
setStages(s || []);
|
||||
setPersonas(p || []);
|
||||
setTeams(sw.auth?.teams || []);
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
}, [teamId, workflow.id]);
|
||||
@@ -259,7 +253,6 @@ function WorkflowEditor({ teamId, workflow, onBack }) {
|
||||
<span class="text-muted" style="font-size:11px;width:24px;">#${i + 1}</span>
|
||||
<strong style="flex:1;">${s.name || `Stage ${i + 1}`}</strong>
|
||||
<span class="badge">${s.stage_mode || '\u2014'}</span>
|
||||
${s.persona_id && html`<span class="badge badge-active">persona</span>`}
|
||||
${s.assignment_team_id && html`<span class="badge">team assign</span>`}
|
||||
<button class="btn-small" onClick=${() => setEditingStage(s.id)}>Edit</button>
|
||||
<button class="btn-small btn-danger" onClick=${() => deleteStage(s.id)}>\u00d7</button>
|
||||
@@ -271,7 +264,6 @@ function WorkflowEditor({ teamId, workflow, onBack }) {
|
||||
${editingStage && html`
|
||||
<${StageForm}
|
||||
stage=${editingStage === 'new' ? null : stages.find(s => s.id === editingStage)}
|
||||
personas=${personas}
|
||||
teams=${teams}
|
||||
onSave=${(data) => editingStage === 'new' ? addStage(data) : updateStage(editingStage, data)}
|
||||
onCancel=${() => setEditingStage(null)}
|
||||
@@ -283,12 +275,10 @@ function WorkflowEditor({ teamId, workflow, onBack }) {
|
||||
|
||||
// ── Stage Form ──────────────────────────────
|
||||
|
||||
function StageForm({ stage, personas, teams, onSave, onCancel }) {
|
||||
function StageForm({ stage, teams, onSave, onCancel }) {
|
||||
const [name, setName] = useState(stage?.name || '');
|
||||
const [mode, setMode] = useState(stage?.stage_mode || 'chat_only');
|
||||
const [personaId, setPersonaId] = useState(stage?.persona_id || '');
|
||||
const [mode, setMode] = useState(stage?.stage_mode || 'form_only');
|
||||
const [assignTeam, setAssignTeam] = useState(stage?.assignment_team_id || '');
|
||||
const [historyMode, setHistoryMode] = useState(stage?.history_mode || 'full');
|
||||
const [autoTransition, setAutoTransition] = useState(stage?.auto_transition || false);
|
||||
const [sla, setSla] = useState(stage?.sla_seconds || '');
|
||||
|
||||
@@ -296,9 +286,7 @@ function StageForm({ stage, personas, teams, onSave, onCancel }) {
|
||||
onSave({
|
||||
name,
|
||||
stage_mode: mode,
|
||||
persona_id: personaId || null,
|
||||
assignment_team_id: assignTeam || null,
|
||||
history_mode: historyMode,
|
||||
auto_transition: autoTransition,
|
||||
sla_seconds: sla ? parseInt(sla, 10) : null,
|
||||
});
|
||||
@@ -319,13 +307,6 @@ function StageForm({ stage, personas, teams, onSave, onCancel }) {
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label>Persona (LLM)</label>
|
||||
<select value=${personaId} onChange=${e => setPersonaId(e.target.value)}>
|
||||
<option value="">\u2014 none \u2014</option>
|
||||
${personas.map(p => html`<option key=${p.id} value=${p.id}>${p.name}</option>`)}
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Queue to Team</label>
|
||||
<select value=${assignTeam} onChange=${e => setAssignTeam(e.target.value)}>
|
||||
@@ -333,14 +314,6 @@ function StageForm({ stage, personas, teams, onSave, onCancel }) {
|
||||
${teams.map(t => html`<option key=${t.id || t.team_id} value=${t.id || t.team_id}>${t.name || t.team_name}</option>`)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label>History Mode</label>
|
||||
<select value=${historyMode} onChange=${e => setHistoryMode(e.target.value)}>
|
||||
${HISTORY_MODES.map(m => html`<option key=${m} value=${m}>${m}</option>`)}
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>SLA (seconds)</label>
|
||||
<input type="number" value=${sla} onInput=${e => setSla(e.target.value)} placeholder="e.g. 3600" />
|
||||
|
||||
Reference in New Issue
Block a user