Compare commits
3 Commits
d2739aabd2
...
df310d9aa5
| Author | SHA1 | Date | |
|---|---|---|---|
| df310d9aa5 | |||
| b82640ba40 | |||
| 66063b08ed |
57
CHANGELOG.md
57
CHANGELOG.md
@@ -2,7 +2,62 @@
|
|||||||
|
|
||||||
All notable changes to Switchboard Core are documented here.
|
All notable changes to Switchboard Core are documented here.
|
||||||
|
|
||||||
## [Unreleased] — v0.2.6
|
## 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
|
||||||
|
|
||||||
|
- **localStorage namespace**: Renamed `cs-appearance` key to `sb-appearance`
|
||||||
|
across appearance settings, base template, and workflow template. One-time
|
||||||
|
migration preserves existing user preferences.
|
||||||
|
- **Policy-gating tests**: Replaced stale `allow_user_byok` / `allow_user_personas`
|
||||||
|
assertions with `allow_registration` check (the only policy still in admin UI).
|
||||||
|
|
||||||
|
### Removed
|
||||||
|
|
||||||
|
- **Dead BYOK nav section** in user settings: empty `BYOK_ITEMS` array,
|
||||||
|
`byokEnabled` state, "BYOK Enabled" footer badge, and the nav group
|
||||||
|
that rendered an empty section.
|
||||||
|
- **Dead personas gate**: `personasEnabled` state and `.filter()` on
|
||||||
|
NAV_ITEMS for a `gate` property no items have. `auth.permissions.changed`
|
||||||
|
listener removed (existed solely for BYOK + personas state).
|
||||||
|
- **Dead Message Font Size**: Slider, `msgFont` state, and `--msg-font`
|
||||||
|
CSS variable application from appearance section and base template.
|
||||||
|
- **Dead policy defaults**: `allow_user_byok` and `allow_user_personas`
|
||||||
|
removed from `PolicyDefaults`, profile bootstrap, permissions handler,
|
||||||
|
and `PublicSettings`. Test seed data cleaned.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## v0.2.6 — Admin Settings Audit
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
|
|||||||
@@ -100,15 +100,15 @@ SDK stabilization, and the first rebuilt extension (tasks).
|
|||||||
|
|
||||||
| Step | Status | Description |
|
| Step | Status | Description |
|
||||||
|------|--------|-------------|
|
|------|--------|-------------|
|
||||||
| User settings E2E | ⬚ | Verify all user settings sections: General (default surface), Appearance (theme, scale, font), Profile (display name, avatar, handle), Connections, Notifications. Remove dead features, fix broken states. |
|
| User settings E2E | ✅ | All 6 user settings sections verified (General, Appearance, Profile, Teams, Connections, Notifications). Dead code removed: BYOK nav section + state, personas gate filter, Message Font Size slider, `auth.permissions.changed` listener. localStorage key renamed `cs-appearance` → `sb-appearance` with one-time migration. |
|
||||||
| Visibility gating | ⬚ | Ensure settings sections only show features that are actually available. Hide empty sections. Respect `user_overridable` flag from extension manifests. |
|
| Visibility gating | ✅ | Dead BYOK/personas policy lookups removed from bootstrap and permissions handlers. `allow_user_byok` removed from `PublicSettings`. `PolicyDefaults` cleaned of `allow_user_byok` and `allow_user_personas`. Dead `msgFont` early-apply removed from base template. Stale policy-gating test assertions replaced. No empty nav sections remain. |
|
||||||
|
|
||||||
### v0.2.8 — Team Admin Settings Audit (Pass 1)
|
### v0.2.8 — Team Admin Settings Audit (Pass 1)
|
||||||
|
|
||||||
| Step | Status | Description |
|
| 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. |
|
| 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. |
|
||||||
| User settings team tab | ⬚ | Verify the Teams section in user settings — team list, join/leave, team-scoped settings. |
|
| 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
|
### v0.2.9 — Builtin Extension Retirement
|
||||||
|
|
||||||
|
|||||||
@@ -74,24 +74,6 @@
|
|||||||
T.assert(Array.isArray(d.data), 'data should be array');
|
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 ──
|
// ── Team Tasks CRUD ──
|
||||||
var teamTaskId = null;
|
var teamTaskId = null;
|
||||||
|
|
||||||
|
|||||||
@@ -340,11 +340,8 @@ func TruncateAll(t *testing.T) {
|
|||||||
`)
|
`)
|
||||||
DB.Exec(`
|
DB.Exec(`
|
||||||
INSERT INTO platform_policies (key, value) VALUES
|
INSERT INTO platform_policies (key, value) VALUES
|
||||||
('allow_user_byok', 'false'),
|
|
||||||
('allow_user_personas', 'false'),
|
|
||||||
('allow_registration', 'true'),
|
('allow_registration', 'true'),
|
||||||
('default_user_active', 'false'),
|
('default_user_active', 'false')
|
||||||
('allow_team_providers', 'true')
|
|
||||||
ON CONFLICT (key) DO NOTHING
|
ON CONFLICT (key) DO NOTHING
|
||||||
`)
|
`)
|
||||||
} else {
|
} else {
|
||||||
@@ -357,11 +354,8 @@ func TruncateAll(t *testing.T) {
|
|||||||
`)
|
`)
|
||||||
DB.Exec(`
|
DB.Exec(`
|
||||||
INSERT INTO platform_policies (key, value) VALUES
|
INSERT INTO platform_policies (key, value) VALUES
|
||||||
('allow_user_byok', 'false'),
|
|
||||||
('allow_user_personas', 'false'),
|
|
||||||
('allow_registration', 'true'),
|
('allow_registration', 'true'),
|
||||||
('default_user_active', 'false'),
|
('default_user_active', 'false')
|
||||||
('allow_team_providers', 'true')
|
|
||||||
ON CONFLICT (key) DO NOTHING
|
ON CONFLICT (key) DO NOTHING
|
||||||
`)
|
`)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -369,7 +369,6 @@ func (h *AdminHandler) PublicSettings(c *gin.Context) {
|
|||||||
"storage_configured": storageConfigured,
|
"storage_configured": storageConfigured,
|
||||||
"policies": gin.H{
|
"policies": gin.H{
|
||||||
"allow_registration": policies["allow_registration"],
|
"allow_registration": policies["allow_registration"],
|
||||||
"allow_user_byok": policies["allow_user_byok"],
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -83,10 +83,6 @@ func (h *ProfileBootstrapHandler) GetBootstrap(c *gin.Context) {
|
|||||||
|
|
||||||
// ── Policies ────────────────────────────
|
// ── Policies ────────────────────────────
|
||||||
policies := make(map[string]bool)
|
policies := make(map[string]bool)
|
||||||
if ps := h.stores.Policies; ps != nil {
|
|
||||||
policies["allow_user_byok"], _ = ps.GetBool(ctx, "allow_user_byok")
|
|
||||||
policies["allow_user_personas"], _ = ps.GetBool(ctx, "allow_user_personas")
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Settings ────────────────────────────
|
// ── Settings ────────────────────────────
|
||||||
settings := make(map[string]interface{})
|
settings := make(map[string]interface{})
|
||||||
|
|||||||
@@ -64,10 +64,6 @@ func (h *ProfilePermissionsHandler) GetMyPermissions(c *gin.Context) {
|
|||||||
|
|
||||||
// Policies that affect UI gating
|
// Policies that affect UI gating
|
||||||
policies := make(map[string]bool)
|
policies := make(map[string]bool)
|
||||||
if ps := h.stores.Policies; ps != nil {
|
|
||||||
policies["allow_user_byok"], _ = ps.GetBool(ctx, "allow_user_byok")
|
|
||||||
policies["allow_user_personas"], _ = ps.GetBool(ctx, "allow_user_personas")
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"permissions": list,
|
"permissions": list,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
package handlers
|
package handlers
|
||||||
|
|
||||||
// v0.38.1: Extension connection handlers — team scope.
|
// Extension connection handlers — team scope.
|
||||||
// Methods on TeamHandler, mirrors team_providers.go.
|
// Methods on TeamHandler.
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|||||||
@@ -78,17 +78,13 @@ type TeamMember struct {
|
|||||||
Email string `json:"email,omitempty"`
|
Email string `json:"email,omitempty"`
|
||||||
DisplayName string `json:"display_name,omitempty"`
|
DisplayName string `json:"display_name,omitempty"`
|
||||||
Username string `json:"username,omitempty"`
|
Username string `json:"username,omitempty"`
|
||||||
UserRole string `json:"user_role,omitempty"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// PLATFORM POLICIES
|
// PLATFORM POLICIES
|
||||||
|
|
||||||
var PolicyDefaults = map[string]string{
|
var PolicyDefaults = map[string]string{
|
||||||
"allow_user_byok": "false",
|
|
||||||
"allow_user_personas": "false",
|
|
||||||
"allow_registration": "true",
|
"allow_registration": "true",
|
||||||
"default_user_active": "false",
|
"default_user_active": "false",
|
||||||
"allow_team_providers": "true",
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// HandleFromName generates a URL-safe @mention handle from a display name.
|
// HandleFromName generates a URL-safe @mention handle from a display name.
|
||||||
|
|||||||
@@ -51,11 +51,11 @@
|
|||||||
if (fav) fav.href = fav.href.replace(/favicon(-light)?\.svg/, resolved === 'light' ? 'favicon-light.svg' : 'favicon.svg');
|
if (fav) fav.href = fav.href.replace(/favicon(-light)?\.svg/, resolved === 'light' ? 'favicon-light.svg' : 'favicon.svg');
|
||||||
} catch(e) {}
|
} catch(e) {}
|
||||||
})();
|
})();
|
||||||
// Early appearance — apply scale + msg font from localStorage on all surfaces.
|
// Early appearance — apply scale from localStorage on all surfaces.
|
||||||
// Scale uses transform on .surface-inner so shell stays fixed, content fills viewport.
|
// Scale uses transform on .surface-inner so shell stays fixed, content fills viewport.
|
||||||
(function() {
|
(function() {
|
||||||
try {
|
try {
|
||||||
var p = JSON.parse(localStorage.getItem('cs-appearance') || '{}');
|
var p = JSON.parse(localStorage.getItem('sb-appearance') || '{}');
|
||||||
if (p.scale && p.scale !== 100) {
|
if (p.scale && p.scale !== 100) {
|
||||||
var s = p.scale / 100;
|
var s = p.scale / 100;
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
@@ -63,7 +63,6 @@
|
|||||||
if (el) { el.style.transform = 'scale('+s+')'; el.style.transformOrigin = 'top left'; el.style.width = (100/s)+'%'; el.style.height = (100/s)+'%'; }
|
if (el) { el.style.transform = 'scale('+s+')'; el.style.transformOrigin = 'top left'; el.style.width = (100/s)+'%'; el.style.height = (100/s)+'%'; }
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (p.msgFont && p.msgFont !== 14) document.documentElement.style.setProperty('--msg-font', p.msgFont + 'px');
|
|
||||||
} catch(e) {}
|
} catch(e) {}
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -163,7 +163,7 @@
|
|||||||
<script>
|
<script>
|
||||||
(function() {
|
(function() {
|
||||||
try {
|
try {
|
||||||
var p = JSON.parse(localStorage.getItem('cs-appearance') || '{}');
|
var p = JSON.parse(localStorage.getItem('sb-appearance') || '{}');
|
||||||
var mode = p.theme || 'system';
|
var mode = p.theme || 'system';
|
||||||
var resolved = mode;
|
var resolved = mode;
|
||||||
if (mode === 'system') resolved = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
if (mode === 'system') resolved = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||||||
|
|||||||
@@ -170,10 +170,6 @@ type TeamStore interface {
|
|||||||
// AddMemberReturningID inserts a team member and returns the row ID.
|
// AddMemberReturningID inserts a team member and returns the row ID.
|
||||||
AddMemberReturningID(ctx context.Context, teamID, userID, role string) (string, error)
|
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 merges a JSON string into the team's settings column.
|
||||||
MergeSettings(ctx context.Context, teamID, settingsJSON string) error
|
MergeSettings(ctx context.Context, teamID, settingsJSON string) error
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,8 +19,7 @@ func SetDB(db *sql.DB) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Dynamic SQL Builder ─────────────────────
|
// ── Dynamic SQL Builder ─────────────────────
|
||||||
// Replaces the copy-pasted addClause/addField pattern
|
// Replaces the copy-pasted addClause/addField pattern.
|
||||||
// found in admin.go, personas.go, team_providers.go, apiconfigs.go.
|
|
||||||
|
|
||||||
// UpdateBuilder constructs a dynamic UPDATE statement.
|
// UpdateBuilder constructs a dynamic UPDATE statement.
|
||||||
type UpdateBuilder struct {
|
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) {
|
func (s *TeamStore) ListMembers(ctx context.Context, teamID string) ([]models.TeamMember, error) {
|
||||||
rows, err := DB.QueryContext(ctx, `
|
rows, err := DB.QueryContext(ctx, `
|
||||||
SELECT tm.id, tm.team_id, tm.user_id, tm.role, tm.joined_at,
|
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
|
FROM team_members tm
|
||||||
JOIN users u ON u.id = tm.user_id
|
JOIN users u ON u.id = tm.user_id
|
||||||
WHERE tm.team_id = $1
|
WHERE tm.team_id = $1
|
||||||
@@ -156,7 +156,7 @@ func (s *TeamStore) ListMembers(ctx context.Context, teamID string) ([]models.Te
|
|||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var m models.TeamMember
|
var m models.TeamMember
|
||||||
err := rows.Scan(&m.ID, &m.TeamID, &m.UserID, &m.Role, &m.JoinedAt,
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -169,12 +169,12 @@ func (s *TeamStore) GetMember(ctx context.Context, teamID, userID string) (*mode
|
|||||||
var m models.TeamMember
|
var m models.TeamMember
|
||||||
err := DB.QueryRowContext(ctx, `
|
err := DB.QueryRowContext(ctx, `
|
||||||
SELECT tm.id, tm.team_id, tm.user_id, tm.role, tm.joined_at,
|
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
|
FROM team_members tm
|
||||||
JOIN users u ON u.id = tm.user_id
|
JOIN users u ON u.id = tm.user_id
|
||||||
WHERE tm.team_id = $1 AND tm.user_id = $2`, teamID, userID).Scan(
|
WHERE tm.team_id = $1 AND tm.user_id = $2`, teamID, userID).Scan(
|
||||||
&m.ID, &m.TeamID, &m.UserID, &m.Role, &m.JoinedAt,
|
&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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -302,19 +302,6 @@ func (s *TeamStore) AddMemberReturningID(ctx context.Context, teamID, userID, ro
|
|||||||
return id, err
|
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 {
|
func (s *TeamStore) MergeSettings(ctx context.Context, teamID, settingsJSON string) error {
|
||||||
_, err := DB.ExecContext(ctx,
|
_, err := DB.ExecContext(ctx,
|
||||||
`UPDATE teams SET settings = COALESCE(settings, '{}'::jsonb) || $1::jsonb WHERE id = $2`,
|
`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) {
|
func (s *TeamStore) ListMembers(ctx context.Context, teamID string) ([]models.TeamMember, error) {
|
||||||
rows, err := DB.QueryContext(ctx, `
|
rows, err := DB.QueryContext(ctx, `
|
||||||
SELECT tm.id, tm.team_id, tm.user_id, tm.role, tm.joined_at,
|
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
|
FROM team_members tm
|
||||||
JOIN users u ON u.id = tm.user_id
|
JOIN users u ON u.id = tm.user_id
|
||||||
WHERE tm.team_id = ?
|
WHERE tm.team_id = ?
|
||||||
@@ -163,7 +163,7 @@ func (s *TeamStore) ListMembers(ctx context.Context, teamID string) ([]models.Te
|
|||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var m models.TeamMember
|
var m models.TeamMember
|
||||||
err := rows.Scan(&m.ID, &m.TeamID, &m.UserID, &m.Role, &m.JoinedAt,
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -176,12 +176,12 @@ func (s *TeamStore) GetMember(ctx context.Context, teamID, userID string) (*mode
|
|||||||
var m models.TeamMember
|
var m models.TeamMember
|
||||||
err := DB.QueryRowContext(ctx, `
|
err := DB.QueryRowContext(ctx, `
|
||||||
SELECT tm.id, tm.team_id, tm.user_id, tm.role, tm.joined_at,
|
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
|
FROM team_members tm
|
||||||
JOIN users u ON u.id = tm.user_id
|
JOIN users u ON u.id = tm.user_id
|
||||||
WHERE tm.team_id = ? AND tm.user_id = ?`, teamID, userID).Scan(
|
WHERE tm.team_id = ? AND tm.user_id = ?`, teamID, userID).Scan(
|
||||||
&m.ID, &m.TeamID, &m.UserID, &m.Role, &m.JoinedAt,
|
&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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -308,19 +308,6 @@ func (s *TeamStore) AddMemberReturningID(ctx context.Context, teamID, userID, ro
|
|||||||
return id, err
|
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 {
|
func (s *TeamStore) MergeSettings(ctx context.Context, teamID, settingsJSON string) error {
|
||||||
_, err := DB.ExecContext(ctx,
|
_, err := DB.ExecContext(ctx,
|
||||||
`UPDATE teams SET settings = json_patch(COALESCE(settings, '{}'), ?) WHERE id = ?`,
|
`UPDATE teams SET settings = json_patch(COALESCE(settings, '{}'), ?) WHERE id = ?`,
|
||||||
|
|||||||
@@ -56,9 +56,6 @@
|
|||||||
.settings-nav-link:hover { background: var(--bg-hover); color: var(--text); text-decoration: none; }
|
.settings-nav-link:hover { background: var(--bg-hover); color: var(--text); text-decoration: none; }
|
||||||
.settings-nav-link.active { background: var(--accent-dim); color: var(--accent); }
|
.settings-nav-link.active { background: var(--accent-dim); color: var(--accent); }
|
||||||
.settings-nav-sep { margin: 8px 0; border-top: 1px solid var(--border); }
|
.settings-nav-sep { margin: 8px 0; border-top: 1px solid var(--border); }
|
||||||
.settings-nav-footer {
|
|
||||||
margin-top: auto; padding: 10px 12px; border-top: 1px solid var(--border);
|
|
||||||
}
|
|
||||||
.settings-content { flex: 1; overflow-y: auto; padding: 28px; }
|
.settings-content { flex: 1; overflow-y: auto; padding: 28px; }
|
||||||
.settings-content h2 { font-size: 18px; font-weight: 600; margin: 0 0 20px; }
|
.settings-content h2 { font-size: 18px; font-weight: 600; margin: 0 0 20px; }
|
||||||
.settings-content .settings-section {
|
.settings-content .settings-section {
|
||||||
|
|||||||
@@ -164,14 +164,9 @@ describe('Admin Preact surface handles settings', () => {
|
|||||||
if (fs.existsSync(settingsPath)) {
|
if (fs.existsSync(settingsPath)) {
|
||||||
const settingsSrc = fs.readFileSync(settingsPath, 'utf-8');
|
const settingsSrc = fs.readFileSync(settingsPath, 'utf-8');
|
||||||
|
|
||||||
it('admin settings component references allow_user_byok', () => {
|
it('admin settings component references allow_registration', () => {
|
||||||
assert.ok(settingsSrc.includes('allow_user_byok'),
|
assert.ok(settingsSrc.includes('allow_registration'),
|
||||||
'MISSING: allow_user_byok in admin settings component');
|
'MISSING: allow_registration in admin settings component');
|
||||||
});
|
|
||||||
|
|
||||||
it('admin settings component references allow_user_personas', () => {
|
|
||||||
assert.ok(settingsSrc.includes('allow_user_personas'),
|
|
||||||
'MISSING: allow_user_personas in admin settings component');
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,9 +1,15 @@
|
|||||||
/**
|
/**
|
||||||
* AppearanceSection — theme, UI scale, message font size
|
* AppearanceSection — theme, UI scale
|
||||||
*/
|
*/
|
||||||
const { html } = window;
|
const { html } = window;
|
||||||
const { useState, useCallback } = hooks;
|
const { useState, useCallback } = hooks;
|
||||||
|
|
||||||
|
// Migrate cs- prefix to sb- (one-time, preserves existing prefs)
|
||||||
|
if (!localStorage.getItem('sb-appearance') && localStorage.getItem('cs-appearance')) {
|
||||||
|
localStorage.setItem('sb-appearance', localStorage.getItem('cs-appearance'));
|
||||||
|
localStorage.removeItem('cs-appearance');
|
||||||
|
}
|
||||||
|
|
||||||
const THEMES = [
|
const THEMES = [
|
||||||
{ key: 'light', icon: '\u2600', label: 'Light' },
|
{ key: 'light', icon: '\u2600', label: 'Light' },
|
||||||
{ key: 'dark', icon: '\u263E', label: 'Dark' },
|
{ key: 'dark', icon: '\u263E', label: 'Dark' },
|
||||||
@@ -11,7 +17,7 @@ const THEMES = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
function getPrefs() {
|
function getPrefs() {
|
||||||
try { return JSON.parse(localStorage.getItem('cs-appearance') || '{}'); }
|
try { return JSON.parse(localStorage.getItem('sb-appearance') || '{}'); }
|
||||||
catch { return {}; }
|
catch { return {}; }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -19,7 +25,6 @@ export function AppearanceSection() {
|
|||||||
const prefs = getPrefs();
|
const prefs = getPrefs();
|
||||||
const [theme, setTheme] = useState(sw.theme?.current || 'system');
|
const [theme, setTheme] = useState(sw.theme?.current || 'system');
|
||||||
const [scale, setScale] = useState(prefs.scale || 100);
|
const [scale, setScale] = useState(prefs.scale || 100);
|
||||||
const [msgFont, setMsgFont] = useState(prefs.msgFont || 14);
|
|
||||||
|
|
||||||
const applyTheme = useCallback((mode) => {
|
const applyTheme = useCallback((mode) => {
|
||||||
setTheme(mode);
|
setTheme(mode);
|
||||||
@@ -30,8 +35,7 @@ export function AppearanceSection() {
|
|||||||
const save = useCallback(() => {
|
const save = useCallback(() => {
|
||||||
const p = getPrefs();
|
const p = getPrefs();
|
||||||
p.scale = scale;
|
p.scale = scale;
|
||||||
p.msgFont = msgFont;
|
localStorage.setItem('sb-appearance', JSON.stringify(p));
|
||||||
localStorage.setItem('cs-appearance', JSON.stringify(p));
|
|
||||||
|
|
||||||
// Apply scale to content area only (not shell/nav)
|
// Apply scale to content area only (not shell/nav)
|
||||||
const inner = document.getElementById('surfaceInner');
|
const inner = document.getElementById('surfaceInner');
|
||||||
@@ -48,10 +52,9 @@ export function AppearanceSection() {
|
|||||||
inner.style.height = '';
|
inner.style.height = '';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
document.documentElement.style.setProperty('--msg-font', msgFont + 'px');
|
|
||||||
|
|
||||||
sw.emit('toast', { message: 'Appearance saved', variant: 'success' });
|
sw.emit('toast', { message: 'Appearance saved', variant: 'success' });
|
||||||
}, [scale, msgFont]);
|
}, [scale]);
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
<div class="settings-section">
|
<div class="settings-section">
|
||||||
@@ -78,18 +81,6 @@ export function AppearanceSection() {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<h3 style="margin-top:16px;">Message Font Size</h3>
|
|
||||||
<div class="form-group">
|
|
||||||
<div style="display:flex;align-items:center;gap:8px;">
|
|
||||||
<input type="range" min="12" max="20" step="1"
|
|
||||||
value=${msgFont}
|
|
||||||
onInput=${e => setMsgFont(parseInt(e.target.value))}
|
|
||||||
style="flex:1;" />
|
|
||||||
<span style="font-size:12px;color:var(--text-2);font-family:var(--mono);min-width:32px;">
|
|
||||||
${msgFont}px
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<button class="btn-md btn-primary" onClick=${save}>Save</button>
|
<button class="btn-md btn-primary" onClick=${save}>Save</button>
|
||||||
`;
|
`;
|
||||||
|
|||||||
@@ -6,14 +6,11 @@
|
|||||||
* __BASE__ — base path
|
* __BASE__ — base path
|
||||||
* __CONFIG_SECTIONS__ — v0.38.3: extension config sections (array|null)
|
* __CONFIG_SECTIONS__ — v0.38.3: extension config sections (array|null)
|
||||||
*
|
*
|
||||||
* Policy flags (BYOK, personas) come from sw.auth.policies,
|
|
||||||
* populated at SDK boot via GET /api/v1/profile/permissions.
|
|
||||||
*
|
|
||||||
* Layout: topbar + left nav + content area (same CSS classes as before).
|
* Layout: topbar + left nav + content area (same CSS classes as before).
|
||||||
* All sections are native Preact components loaded lazily.
|
* All sections are native Preact components loaded lazily.
|
||||||
*/
|
*/
|
||||||
const { html } = window;
|
const { html } = window;
|
||||||
const { useState, useEffect, useCallback, useMemo } = hooks;
|
const { useState, useEffect, useCallback } = hooks;
|
||||||
const { render } = preact;
|
const { render } = preact;
|
||||||
|
|
||||||
import { ToastContainer } from '../../primitives/toast.js';
|
import { ToastContainer } from '../../primitives/toast.js';
|
||||||
@@ -40,8 +37,6 @@ const NAV_ITEMS = [
|
|||||||
{ key: 'notifications', label: 'Notifications' },
|
{ key: 'notifications', label: 'Notifications' },
|
||||||
];
|
];
|
||||||
|
|
||||||
const BYOK_ITEMS = [];
|
|
||||||
|
|
||||||
// ── Section title map ───────────────────────
|
// ── Section title map ───────────────────────
|
||||||
const SECTION_TITLES = {
|
const SECTION_TITLES = {
|
||||||
general: 'General', appearance: 'Appearance',
|
general: 'General', appearance: 'Appearance',
|
||||||
@@ -67,22 +62,8 @@ function SettingsSurface() {
|
|||||||
const BASE = window.__BASE__ || '';
|
const BASE = window.__BASE__ || '';
|
||||||
const section = window.__SECTION__ || 'general';
|
const section = window.__SECTION__ || 'general';
|
||||||
|
|
||||||
const policies = sw.auth?.policies || {};
|
|
||||||
const [byokEnabled, setByokEnabled] = useState(policies.allow_user_byok === 'true');
|
|
||||||
const [personasEnabled, setPersonasEnabled] = useState(policies.allow_user_personas === 'true');
|
|
||||||
const [SectionComponent, setSectionComponent] = useState(null);
|
const [SectionComponent, setSectionComponent] = useState(null);
|
||||||
|
|
||||||
// Update when permissions refresh (e.g. after boot or policy change)
|
|
||||||
useEffect(() => {
|
|
||||||
function _onPermsChanged() {
|
|
||||||
const p = sw.auth?.policies || {};
|
|
||||||
setByokEnabled(p.allow_user_byok === 'true');
|
|
||||||
setPersonasEnabled(p.allow_user_personas === 'true');
|
|
||||||
}
|
|
||||||
sw.on('auth.permissions.changed', _onPermsChanged);
|
|
||||||
return () => sw.off('auth.permissions.changed', _onPermsChanged);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Load the section component
|
// Load the section component
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setSectionComponent(null);
|
setSectionComponent(null);
|
||||||
@@ -153,9 +134,7 @@ function SettingsSurface() {
|
|||||||
<div style="display:flex;flex:1;min-height:0;">
|
<div style="display:flex;flex:1;min-height:0;">
|
||||||
${/* Left Nav */''}
|
${/* Left Nav */''}
|
||||||
<div class="settings-nav">
|
<div class="settings-nav">
|
||||||
${NAV_ITEMS
|
${NAV_ITEMS.map(item => html`
|
||||||
.filter(item => !item.gate || (item.gate === 'personas' && personasEnabled))
|
|
||||||
.map(item => html`
|
|
||||||
<a href="${BASE}/settings/${item.key}"
|
<a href="${BASE}/settings/${item.key}"
|
||||||
class="settings-nav-link ${section === item.key ? 'active' : ''}"
|
class="settings-nav-link ${section === item.key ? 'active' : ''}"
|
||||||
onClick=${onNavClick}>
|
onClick=${onNavClick}>
|
||||||
@@ -163,27 +142,6 @@ function SettingsSurface() {
|
|||||||
</a>
|
</a>
|
||||||
`)}
|
`)}
|
||||||
|
|
||||||
${byokEnabled && html`
|
|
||||||
<div>
|
|
||||||
<div class="settings-nav-sep"></div>
|
|
||||||
${BYOK_ITEMS.map(item => html`
|
|
||||||
<a href="${BASE}/settings/${item.key}"
|
|
||||||
class="settings-nav-link ${section === item.key ? 'active' : ''}"
|
|
||||||
onClick=${onNavClick}>
|
|
||||||
${item.label}
|
|
||||||
</a>
|
|
||||||
`)}
|
|
||||||
</div>
|
|
||||||
<div class="settings-nav-footer">
|
|
||||||
<div style="display:flex;align-items:center;gap:6px;margin-bottom:4px;">
|
|
||||||
<span style="font-size:11px;font-weight:600;color:var(--success);">BYOK Enabled</span>
|
|
||||||
</div>
|
|
||||||
<p style="font-size:10px;color:var(--text-3);margin:0;line-height:1.5;">
|
|
||||||
Admin has enabled Bring Your Own Key
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
`}
|
|
||||||
|
|
||||||
${EXT_NAV_ITEMS.length > 0 && html`
|
${EXT_NAV_ITEMS.length > 0 && html`
|
||||||
<div>
|
<div>
|
||||||
<div class="settings-nav-sep"></div>
|
<div class="settings-nav-sep"></div>
|
||||||
|
|||||||
@@ -10,8 +10,7 @@ const { html } = window;
|
|||||||
const { useState, useEffect, useCallback } = hooks;
|
const { useState, useEffect, useCallback } = hooks;
|
||||||
|
|
||||||
const ENTRY_MODES = ['public_link', 'team_only'];
|
const ENTRY_MODES = ['public_link', 'team_only'];
|
||||||
const STAGE_MODES = ['chat_only', 'form_only', 'form_chat', 'review'];
|
const STAGE_MODES = ['form_only', 'form_chat', 'review', 'custom'];
|
||||||
const HISTORY_MODES = ['full', 'summary', 'fresh'];
|
|
||||||
const TABS = ['Workflows', 'Assignments', 'Monitor'];
|
const TABS = ['Workflows', 'Assignments', 'Monitor'];
|
||||||
|
|
||||||
function _timeAgo(ts) {
|
function _timeAgo(ts) {
|
||||||
@@ -143,18 +142,13 @@ function WorkflowsTab({ teamId }) {
|
|||||||
|
|
||||||
function WorkflowEditor({ teamId, workflow, onBack }) {
|
function WorkflowEditor({ teamId, workflow, onBack }) {
|
||||||
const [stages, setStages] = useState([]);
|
const [stages, setStages] = useState([]);
|
||||||
const [personas, setPersonas] = useState([]);
|
|
||||||
const [teams, setTeams] = useState([]);
|
const [teams, setTeams] = useState([]);
|
||||||
const [editingStage, setEditingStage] = useState(null); // null | 'new' | stage_id
|
const [editingStage, setEditingStage] = useState(null); // null | 'new' | stage_id
|
||||||
|
|
||||||
const loadStages = useCallback(async () => {
|
const loadStages = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const [s, p] = await Promise.all([
|
const s = await sw.api.teams.workflowStages(teamId, workflow.id);
|
||||||
sw.api.teams.workflowStages(teamId, workflow.id),
|
|
||||||
sw.api.teams.personas(teamId),
|
|
||||||
]);
|
|
||||||
setStages(s || []);
|
setStages(s || []);
|
||||||
setPersonas(p || []);
|
|
||||||
setTeams(sw.auth?.teams || []);
|
setTeams(sw.auth?.teams || []);
|
||||||
} catch (e) { sw.toast(e.message, 'error'); }
|
} catch (e) { sw.toast(e.message, 'error'); }
|
||||||
}, [teamId, workflow.id]);
|
}, [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>
|
<span class="text-muted" style="font-size:11px;width:24px;">#${i + 1}</span>
|
||||||
<strong style="flex:1;">${s.name || `Stage ${i + 1}`}</strong>
|
<strong style="flex:1;">${s.name || `Stage ${i + 1}`}</strong>
|
||||||
<span class="badge">${s.stage_mode || '\u2014'}</span>
|
<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>`}
|
${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" onClick=${() => setEditingStage(s.id)}>Edit</button>
|
||||||
<button class="btn-small btn-danger" onClick=${() => deleteStage(s.id)}>\u00d7</button>
|
<button class="btn-small btn-danger" onClick=${() => deleteStage(s.id)}>\u00d7</button>
|
||||||
@@ -271,7 +264,6 @@ function WorkflowEditor({ teamId, workflow, onBack }) {
|
|||||||
${editingStage && html`
|
${editingStage && html`
|
||||||
<${StageForm}
|
<${StageForm}
|
||||||
stage=${editingStage === 'new' ? null : stages.find(s => s.id === editingStage)}
|
stage=${editingStage === 'new' ? null : stages.find(s => s.id === editingStage)}
|
||||||
personas=${personas}
|
|
||||||
teams=${teams}
|
teams=${teams}
|
||||||
onSave=${(data) => editingStage === 'new' ? addStage(data) : updateStage(editingStage, data)}
|
onSave=${(data) => editingStage === 'new' ? addStage(data) : updateStage(editingStage, data)}
|
||||||
onCancel=${() => setEditingStage(null)}
|
onCancel=${() => setEditingStage(null)}
|
||||||
@@ -283,12 +275,10 @@ function WorkflowEditor({ teamId, workflow, onBack }) {
|
|||||||
|
|
||||||
// ── Stage Form ──────────────────────────────
|
// ── Stage Form ──────────────────────────────
|
||||||
|
|
||||||
function StageForm({ stage, personas, teams, onSave, onCancel }) {
|
function StageForm({ stage, teams, onSave, onCancel }) {
|
||||||
const [name, setName] = useState(stage?.name || '');
|
const [name, setName] = useState(stage?.name || '');
|
||||||
const [mode, setMode] = useState(stage?.stage_mode || 'chat_only');
|
const [mode, setMode] = useState(stage?.stage_mode || 'form_only');
|
||||||
const [personaId, setPersonaId] = useState(stage?.persona_id || '');
|
|
||||||
const [assignTeam, setAssignTeam] = useState(stage?.assignment_team_id || '');
|
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 [autoTransition, setAutoTransition] = useState(stage?.auto_transition || false);
|
||||||
const [sla, setSla] = useState(stage?.sla_seconds || '');
|
const [sla, setSla] = useState(stage?.sla_seconds || '');
|
||||||
|
|
||||||
@@ -296,9 +286,7 @@ function StageForm({ stage, personas, teams, onSave, onCancel }) {
|
|||||||
onSave({
|
onSave({
|
||||||
name,
|
name,
|
||||||
stage_mode: mode,
|
stage_mode: mode,
|
||||||
persona_id: personaId || null,
|
|
||||||
assignment_team_id: assignTeam || null,
|
assignment_team_id: assignTeam || null,
|
||||||
history_mode: historyMode,
|
|
||||||
auto_transition: autoTransition,
|
auto_transition: autoTransition,
|
||||||
sla_seconds: sla ? parseInt(sla, 10) : null,
|
sla_seconds: sla ? parseInt(sla, 10) : null,
|
||||||
});
|
});
|
||||||
@@ -319,13 +307,6 @@ function StageForm({ stage, personas, teams, onSave, onCancel }) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-row">
|
<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">
|
<div class="form-group">
|
||||||
<label>Queue to Team</label>
|
<label>Queue to Team</label>
|
||||||
<select value=${assignTeam} onChange=${e => setAssignTeam(e.target.value)}>
|
<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>`)}
|
${teams.map(t => html`<option key=${t.id || t.team_id} value=${t.id || t.team_id}>${t.name || t.team_name}</option>`)}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</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">
|
<div class="form-group">
|
||||||
<label>SLA (seconds)</label>
|
<label>SLA (seconds)</label>
|
||||||
<input type="number" value=${sla} onInput=${e => setSla(e.target.value)} placeholder="e.g. 3600" />
|
<input type="number" value=${sla} onInput=${e => setSla(e.target.value)} placeholder="e.g. 3600" />
|
||||||
|
|||||||
Reference in New Issue
Block a user