diff --git a/ROADMAP.md b/ROADMAP.md index 8304f8a..048f9d0 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -161,11 +161,12 @@ The missing middle tier: scoped administration without system-admin access. (one person can be admin of Team A, member of Team B) **Onboarding** -- [ ] Registration approval → assign role + team(s) during approval +- [x] Registration approval → assign role + team(s) during approval (upgrade from current binary approve → assign-and-activate) - [x] System admin creates teams + assigns first team admin -- [ ] Team admin self-manages: add/remove members, create team presets, - manage team channels, view team usage +- [x] Team admin self-manages: add/remove members + (RequireTeamAdmin middleware, scoped /teams/:teamId routes) +- [ ] Team admin: create team presets, manage team channels, view team usage **Private Provider Policy** - [x] `is_private` flag on provider configs (marks local/self-hosted endpoints) diff --git a/VERSION b/VERSION index 8adc70f..6f4eebd 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.8.0 \ No newline at end of file +0.8.1 diff --git a/server/handlers/admin.go b/server/handlers/admin.go index 98655e7..e2983c1 100644 --- a/server/handlers/admin.go +++ b/server/handlers/admin.go @@ -17,15 +17,22 @@ import ( // ── Types ─────────────────────────────────── type adminUserResponse struct { - ID string `json:"id"` - Username string `json:"username"` - Email string `json:"email"` - DisplayName *string `json:"display_name"` - Role string `json:"role"` - IsActive bool `json:"is_active"` - CreatedAt string `json:"created_at"` - UpdatedAt string `json:"updated_at"` - LastLoginAt *string `json:"last_login_at"` + ID string `json:"id"` + Username string `json:"username"` + Email string `json:"email"` + DisplayName *string `json:"display_name"` + Role string `json:"role"` + IsActive bool `json:"is_active"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` + LastLoginAt *string `json:"last_login_at"` + Teams []userTeamMembership `json:"teams,omitempty"` +} + +type userTeamMembership struct { + TeamID string `json:"team_id"` + TeamName string `json:"team_name"` + Role string `json:"role"` } type adminCreateUserRequest struct { @@ -44,7 +51,9 @@ type updateUserRoleRequest struct { } type updateUserActiveRequest struct { - IsActive bool `json:"is_active"` + IsActive bool `json:"is_active"` + TeamIDs []string `json:"team_ids,omitempty"` // assign to teams on activation + TeamRole string `json:"team_role,omitempty"` // role for team assignment (default: member) } type globalSettingResponse struct { @@ -98,6 +107,38 @@ func (h *AdminHandler) ListUsers(c *gin.Context) { users = append(users, u) } + // Batch-load team memberships for all users + if len(users) > 0 { + userIDs := make([]interface{}, len(users)) + placeholders := make([]string, len(users)) + userIdx := make(map[string]int) // user_id → index in users slice + for i, u := range users { + userIDs[i] = u.ID + placeholders[i] = "$" + strconv.Itoa(i+1) + userIdx[u.ID] = i + } + tmRows, err := database.DB.Query(` + SELECT tm.user_id, t.id, t.name, tm.role + FROM team_members tm + JOIN teams t ON t.id = tm.team_id + WHERE tm.user_id IN (`+strings.Join(placeholders, ",")+`) + ORDER BY t.name ASC + `, userIDs...) + if err == nil { + defer tmRows.Close() + for tmRows.Next() { + var uid, tid, tname, trole string + if tmRows.Scan(&uid, &tid, &tname, &trole) == nil { + if idx, ok := userIdx[uid]; ok { + users[idx].Teams = append(users[idx].Teams, userTeamMembership{ + TeamID: tid, TeamName: tname, Role: trole, + }) + } + } + } + } + } + c.JSON(http.StatusOK, paginatedResponse{ Data: users, Page: page, @@ -251,7 +292,30 @@ func (h *AdminHandler) ToggleUserActive(c *gin.Context) { return } - c.JSON(http.StatusOK, gin.H{"message": "user updated", "is_active": req.IsActive}) + // On activation, optionally assign to teams + teamsAssigned := 0 + if req.IsActive && len(req.TeamIDs) > 0 { + role := req.TeamRole + if role == "" { + role = "member" + } + for _, teamID := range req.TeamIDs { + _, err := database.DB.Exec(` + INSERT INTO team_members (team_id, user_id, role) + VALUES ($1, $2, $3) + ON CONFLICT (team_id, user_id) DO NOTHING + `, teamID, targetID, role) + if err == nil { + teamsAssigned++ + } + } + } + + c.JSON(http.StatusOK, gin.H{ + "message": "user updated", + "is_active": req.IsActive, + "teams_assigned": teamsAssigned, + }) } // ── Delete User ───────────────────────────── diff --git a/server/handlers/apiconfigs.go b/server/handlers/apiconfigs.go index 0d4f882..7ea5f64 100644 --- a/server/handlers/apiconfigs.go +++ b/server/handlers/apiconfigs.go @@ -438,6 +438,7 @@ func (h *APIConfigHandler) ListEnabledModels(c *gin.Context) { PresetID string `json:"preset_id,omitempty"` PresetScope string `json:"preset_scope,omitempty"` PresetAvatar string `json:"preset_avatar,omitempty"` + PresetTeamName string `json:"preset_team_name,omitempty"` } models := make([]enabledModel, 0) @@ -538,30 +539,35 @@ func (h *APIConfigHandler) ListEnabledModels(c *gin.Context) { } } - // ── 3. Active presets (global + user's personal + shared) ── + // ── 3. Active presets (global + user's team + user's personal + shared) ── presetRows, err := database.DB.Query(` SELECT mp.id, mp.name, mp.description, mp.base_model_id, mp.api_config_id, mp.icon, mp.avatar, mp.scope, mp.temperature, mp.max_tokens, - COALESCE(ac.provider, '') as provider, COALESCE(ac.name, '') as provider_name + COALESCE(ac.provider, '') as provider, COALESCE(ac.name, '') as provider_name, + COALESCE(t.name, '') as team_name FROM model_presets mp LEFT JOIN api_configs ac ON mp.api_config_id = ac.id + LEFT JOIN teams t ON mp.team_id = t.id WHERE mp.is_active = true AND ( mp.scope = 'global' OR (mp.scope = 'personal' AND mp.created_by = $1) OR (mp.scope = 'personal' AND mp.is_shared = true) + OR (mp.scope = 'team' AND mp.team_id IN ( + SELECT team_id FROM team_members WHERE user_id = $1 + )) ) ORDER BY mp.scope ASC, mp.name ASC `, userID) if err == nil { defer presetRows.Close() for presetRows.Next() { - var presetID, name, description, baseModelID, icon, avatar, scope, provID, provName string + var presetID, name, description, baseModelID, icon, avatar, scope, provID, provName, teamName string var apiConfigID *string var temp *float64 var maxTok *int if err := presetRows.Scan(&presetID, &name, &description, &baseModelID, &apiConfigID, - &icon, &avatar, &scope, &temp, &maxTok, &provID, &provName); err != nil { + &icon, &avatar, &scope, &temp, &maxTok, &provID, &provName, &teamName); err != nil { continue } @@ -608,17 +614,18 @@ func (h *APIConfigHandler) ListEnabledModels(c *gin.Context) { } models = append(models, enabledModel{ - ID: presetID, - ModelID: baseModelID, - DisplayName: &displayName, - Provider: provID, - ProviderName: provName, - ConfigID: cfgID, - Capabilities: caps, - IsPreset: true, - PresetID: presetID, - PresetScope: scope, - PresetAvatar: avatar, + ID: presetID, + ModelID: baseModelID, + DisplayName: &displayName, + Provider: provID, + ProviderName: provName, + ConfigID: cfgID, + Capabilities: caps, + IsPreset: true, + PresetID: presetID, + PresetScope: scope, + PresetAvatar: avatar, + PresetTeamName: teamName, }) } } diff --git a/server/handlers/presets.go b/server/handlers/presets.go index 52da00d..a9f51a6 100644 --- a/server/handlers/presets.go +++ b/server/handlers/presets.go @@ -58,7 +58,7 @@ type presetResponse struct { // These require user_providers_enabled for personal presets. // ListUserPresets returns all presets visible to the user: -// their own personal presets + all global presets + shared presets. +// their own personal presets + all global presets + shared presets + team presets. // GET /api/v1/presets func (h *PresetHandler) ListUserPresets(c *gin.Context) { userID := getUserID(c) @@ -76,6 +76,9 @@ func (h *PresetHandler) ListUserPresets(c *gin.Context) { mp.scope = 'global' OR (mp.scope = 'personal' AND mp.created_by = $1) OR (mp.scope = 'personal' AND mp.is_shared = true) + OR (mp.scope = 'team' AND mp.team_id IN ( + SELECT team_id FROM team_members WHERE user_id = $1 + )) ) ORDER BY mp.scope ASC, mp.name ASC `, userID) @@ -505,6 +508,9 @@ func ResolvePreset(presetID, userID string) *models.ModelPreset { scope = 'global' OR (scope = 'personal' AND created_by = $2) OR (scope = 'personal' AND is_shared = true) + OR (scope = 'team' AND team_id IN ( + SELECT team_id FROM team_members WHERE user_id = $2 + )) ) `, presetID, userID).Scan( &p.ID, &p.Name, &p.BaseModelID, &p.APIConfigID, &p.SystemPrompt, @@ -515,3 +521,117 @@ func ResolvePreset(presetID, userID string) *models.ModelPreset { } return &p } + +// ── Team Preset Endpoints ───────────────── +// Team admins can create/manage presets scoped to their team. + +type createTeamPresetRequest struct { + Name string `json:"name" binding:"required"` + Description string `json:"description"` + BaseModelID string `json:"base_model_id" binding:"required"` + APIConfigID *string `json:"api_config_id,omitempty"` + SystemPrompt string `json:"system_prompt"` + Temperature *float64 `json:"temperature,omitempty"` + MaxTokens *int `json:"max_tokens,omitempty"` + Icon string `json:"icon,omitempty"` +} + +// ListTeamPresets returns presets scoped to a team. +// GET /api/v1/teams/:teamId/presets +func (h *PresetHandler) ListTeamPresets(c *gin.Context) { + teamID := getTeamID(c) + + rows, err := database.DB.Query(` + SELECT mp.id, mp.name, mp.description, mp.base_model_id, mp.api_config_id, + mp.system_prompt, mp.temperature, mp.max_tokens, mp.tools_enabled, + mp.scope, mp.team_id, mp.created_by, mp.is_shared, mp.is_active, + mp.icon, mp.avatar, mp.created_at, mp.updated_at, + COALESCE(ac.name, '') as provider_name + FROM model_presets mp + LEFT JOIN api_configs ac ON mp.api_config_id = ac.id + WHERE mp.team_id = $1 AND mp.scope = 'team' + ORDER BY mp.name ASC + `, teamID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list team presets"}) + return + } + defer rows.Close() + + presets := make([]presetResponse, 0) + for rows.Next() { + var p presetResponse + if err := rows.Scan( + &p.ID, &p.Name, &p.Description, &p.BaseModelID, &p.APIConfigID, + &p.SystemPrompt, &p.Temperature, &p.MaxTokens, &p.ToolsEnabled, + &p.Scope, &p.TeamID, &p.CreatedBy, &p.IsShared, &p.IsActive, + &p.Icon, &p.Avatar, &p.CreatedAt, &p.UpdatedAt, &p.ProviderName, + ); err != nil { + continue + } + presets = append(presets, p) + } + + c.JSON(http.StatusOK, gin.H{"presets": presets}) +} + +// CreateTeamPreset creates a preset scoped to a team. +// POST /api/v1/teams/:teamId/presets +func (h *PresetHandler) CreateTeamPreset(c *gin.Context) { + teamID := getTeamID(c) + userID := getUserID(c) + + var req createTeamPresetRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + req.Name = strings.TrimSpace(req.Name) + if req.Name == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "name is required"}) + return + } + + toolsJSON := "[]" + + var id string + err := database.DB.QueryRow(` + INSERT INTO model_presets (name, description, base_model_id, api_config_id, + system_prompt, temperature, max_tokens, tools_enabled, + scope, team_id, created_by, is_shared, icon) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8::jsonb, 'team', $9, $10, true, $11) + RETURNING id + `, req.Name, req.Description, req.BaseModelID, req.APIConfigID, + req.SystemPrompt, req.Temperature, req.MaxTokens, toolsJSON, + teamID, userID, req.Icon, + ).Scan(&id) + + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create team preset: " + err.Error()}) + return + } + + c.JSON(http.StatusCreated, gin.H{"id": id}) +} + +// DeleteTeamPreset deletes a team-scoped preset. +// DELETE /api/v1/teams/:teamId/presets/:id +func (h *PresetHandler) DeleteTeamPreset(c *gin.Context) { + teamID := getTeamID(c) + presetID := c.Param("id") + + res, err := database.DB.Exec(` + DELETE FROM model_presets WHERE id = $1 AND team_id = $2 AND scope = 'team' + `, presetID, teamID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "delete failed"}) + return + } + if n, _ := res.RowsAffected(); n == 0 { + c.JSON(http.StatusNotFound, gin.H{"error": "preset not found"}) + return + } + + c.JSON(http.StatusOK, gin.H{"ok": true}) +} diff --git a/server/handlers/teams.go b/server/handlers/teams.go index 3bfaa1d..7f28850 100644 --- a/server/handlers/teams.go +++ b/server/handlers/teams.go @@ -269,7 +269,7 @@ func (h *TeamHandler) DeleteTeam(c *gin.Context) { // ── Members: List ─────────────────────────── func (h *TeamHandler) ListMembers(c *gin.Context) { - teamID := c.Param("id") + teamID := getTeamID(c) rows, err := database.DB.Query(` SELECT tm.id, tm.user_id, tm.role, tm.joined_at, @@ -312,7 +312,7 @@ func (h *TeamHandler) ListMembers(c *gin.Context) { // ── Members: Add ──────────────────────────── func (h *TeamHandler) AddMember(c *gin.Context) { - teamID := c.Param("id") + teamID := getTeamID(c) var req addMemberRequest if err := c.ShouldBindJSON(&req); err != nil { @@ -445,6 +445,14 @@ func (h *TeamHandler) MyTeams(c *gin.Context) { // ── Helpers ───────────────────────────────── +// getTeamID extracts team ID from either :id (admin routes) or :teamId (team-scoped routes). +func getTeamID(c *gin.Context) string { + if id := c.Param("teamId"); id != "" { + return id + } + return c.Param("id") +} + // isUniqueViolation checks if a PG error is a unique constraint violation. func isUniqueViolation(err error) bool { if err == nil { diff --git a/server/main.go b/server/main.go index cb5cd21..649049b 100644 --- a/server/main.go +++ b/server/main.go @@ -155,6 +155,22 @@ func main() { // Teams (user: my teams) teams := handlers.NewTeamHandler() protected.GET("/teams/mine", teams.MyTeams) + + // Team admin self-service (requires team admin role, not sys-admin) + teamScoped := protected.Group("/teams/:teamId") + teamScoped.Use(middleware.RequireTeamAdmin()) + { + teamScoped.GET("/members", teams.ListMembers) + teamScoped.POST("/members", teams.AddMember) + teamScoped.PUT("/members/:memberId", teams.UpdateMember) + teamScoped.DELETE("/members/:memberId", teams.RemoveMember) + + // Team presets + teamPresets := handlers.NewPresetHandler() + teamScoped.GET("/presets", teamPresets.ListTeamPresets) + teamScoped.POST("/presets", teamPresets.CreateTeamPreset) + teamScoped.DELETE("/presets/:id", teamPresets.DeleteTeamPreset) + } protected.POST("/notes", notes.Create) protected.GET("/notes/search", notes.Search) protected.GET("/notes/folders", notes.ListFolders) diff --git a/server/middleware/team.go b/server/middleware/team.go new file mode 100644 index 0000000..bf58e9d --- /dev/null +++ b/server/middleware/team.go @@ -0,0 +1,84 @@ +package middleware + +import ( + "net/http" + + "github.com/gin-gonic/gin" + + "git.gobha.me/xcaliber/chat-switchboard/database" +) + +// RequireTeamAdmin returns middleware that restricts access to users who are +// admins of the team identified by :teamId in the URL path. +// System admins (role=admin) are always allowed through. +// Must be used after Auth() middleware. +func RequireTeamAdmin() gin.HandlerFunc { + return func(c *gin.Context) { + // System admins bypass team check + role, _ := c.Get("role") + if role == "admin" { + c.Next() + return + } + + userID, _ := c.Get("user_id") + teamID := c.Param("teamId") + if teamID == "" { + c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{ + "error": "team ID required", + }) + return + } + + var teamRole string + err := database.DB.QueryRow(` + SELECT role FROM team_members + WHERE team_id = $1 AND user_id = $2 + `, teamID, userID).Scan(&teamRole) + + if err != nil || teamRole != "admin" { + c.AbortWithStatusJSON(http.StatusForbidden, gin.H{ + "error": "team admin access required", + }) + return + } + + c.Next() + } +} + +// RequireTeamMember returns middleware that restricts access to users who +// belong to the team identified by :teamId (any role). +// System admins are always allowed through. +func RequireTeamMember() gin.HandlerFunc { + return func(c *gin.Context) { + role, _ := c.Get("role") + if role == "admin" { + c.Next() + return + } + + userID, _ := c.Get("user_id") + teamID := c.Param("teamId") + if teamID == "" { + c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{ + "error": "team ID required", + }) + return + } + + var exists bool + database.DB.QueryRow(` + SELECT EXISTS(SELECT 1 FROM team_members WHERE team_id = $1 AND user_id = $2) + `, teamID, userID).Scan(&exists) + + if !exists { + c.AbortWithStatusJSON(http.StatusForbidden, gin.H{ + "error": "team membership required", + }) + return + } + + c.Next() + } +} diff --git a/src/css/styles.css b/src/css/styles.css index 818cb29..a4b456e 100644 --- a/src/css/styles.css +++ b/src/css/styles.css @@ -1078,6 +1078,12 @@ button { font-family: var(--font); cursor: pointer; } .badge-user { background: var(--bg-raised); color: var(--text-3); font-size: 10px; padding: 1px 8px; border-radius: 4px; } .badge-inactive { background: var(--danger); color: #fff; font-size: 10px; padding: 1px 8px; border-radius: 4px; } .badge-pending { background: rgba(234,179,8,0.85); color: #000; font-size: 10px; padding: 1px 8px; border-radius: 4px; } +.badge-team { background: rgba(59,130,246,0.2); color: #93c5fd; font-size: 10px; padding: 1px 8px; border-radius: 4px; margin-left: 2px; } +.admin-approve-form { padding: 10px 12px; margin: -4px 0 8px; background: rgba(255,255,255,0.03); border-radius: 0 0 8px 8px; border-top: 1px solid var(--border); } +.admin-approve-form .checkbox-label { display: block; margin: 2px 0; } +.btn-approve { background: rgba(34,197,94,0.15); color: #4ade80; border: 1px solid rgba(34,197,94,0.3); border-radius: 4px; padding: 3px 10px; cursor: pointer; font-size: 12px; } +.team-card { padding: 8px 10px; background: rgba(255,255,255,0.03); border-radius: 6px; margin-bottom: 6px; } +.team-card-info { display: flex; align-items: center; gap: 8px; } .badge-private { background: rgba(139,92,246,0.85); color: #fff; font-size: 10px; padding: 1px 8px; border-radius: 4px; } .admin-user-row.user-inactive { opacity: 0.7; } .loading { color: var(--text-3); font-size: 13px; padding: 0.5rem; } diff --git a/src/index.html b/src/index.html index 6ad5fb6..3def882 100644 --- a/src/index.html +++ b/src/index.html @@ -250,6 +250,10 @@ +

Chat

diff --git a/src/js/api.js b/src/js/api.js index 2aeec6d..1fe8611 100644 --- a/src/js/api.js +++ b/src/js/api.js @@ -255,7 +255,12 @@ const API = { }, adminResetPassword(id, pw) { return this._post(`/api/v1/admin/users/${id}/reset-password`, { new_password: pw }); }, adminUpdateRole(id, role) { return this._put(`/api/v1/admin/users/${id}/role`, { role }); }, - adminToggleActive(id, active) { return this._put(`/api/v1/admin/users/${id}/active`, { is_active: active }); }, + adminToggleActive(id, active, teamIds, teamRole) { + const body = { is_active: active }; + if (teamIds?.length) body.team_ids = teamIds; + if (teamRole) body.team_role = teamRole; + return this._put(`/api/v1/admin/users/${id}/active`, body); + }, adminDeleteUser(id) { return this._del(`/api/v1/admin/users/${id}`); }, adminGetSettings() { return this._get('/api/v1/admin/settings'); }, getPublicSettings() { return this._get('/api/v1/settings/public'); }, @@ -299,6 +304,15 @@ const API = { // ── User Teams ────────────────────────── listMyTeams() { return this._get('/api/v1/teams/mine'); }, + // ── Team Admin Self-Service ───────────── + teamListMembers(teamId) { return this._get(`/api/v1/teams/${teamId}/members`); }, + teamAddMember(teamId, userId, role) { return this._post(`/api/v1/teams/${teamId}/members`, { user_id: userId, role }); }, + teamUpdateMember(teamId, memberId, role) { return this._put(`/api/v1/teams/${teamId}/members/${memberId}`, { role }); }, + teamRemoveMember(teamId, memberId) { return this._del(`/api/v1/teams/${teamId}/members/${memberId}`); }, + teamListPresets(teamId) { return this._get(`/api/v1/teams/${teamId}/presets`); }, + teamCreatePreset(teamId, preset) { return this._post(`/api/v1/teams/${teamId}/presets`, preset); }, + teamDeletePreset(teamId, presetId) { return this._del(`/api/v1/teams/${teamId}/presets/${presetId}`); }, + // ── User Presets ───────────────────────── listPresets() { return this._get('/api/v1/presets'); }, createPreset(preset) { return this._post('/api/v1/presets', preset); }, diff --git a/src/js/app.js b/src/js/app.js index 97dab86..70b4f9f 100644 --- a/src/js/app.js +++ b/src/js/app.js @@ -243,17 +243,19 @@ async function fetchModels() { presetId: m.preset_id || null, presetScope: m.preset_scope || null, presetAvatar: m.preset_avatar || null, + presetTeamName: m.preset_team_name || null, }; }); - // Sort: presets first (global before personal), then regular models + // Sort: presets first (global > team > personal), then regular models + const scopeOrder = { global: 0, team: 1, personal: 2 }; App.models.sort((a, b) => { if (a.isPreset && !b.isPreset) return -1; if (!a.isPreset && b.isPreset) return 1; if (a.isPreset && b.isPreset) { - if (a.presetScope !== b.presetScope) { - return a.presetScope === 'global' ? -1 : 1; - } + const sa = scopeOrder[a.presetScope] ?? 9; + const sb = scopeOrder[b.presetScope] ?? 9; + if (sa !== sb) return sa - sb; } return a.name.localeCompare(b.name); }); @@ -1088,6 +1090,7 @@ function initListeners() { document.getElementById('adminTeamBackBtn')?.addEventListener('click', () => { document.getElementById('adminTeamDetail').style.display = 'none'; document.getElementById('adminTeamList').style.display = ''; + UI.loadAdminTeams(true); }); document.getElementById('adminAddMemberBtn')?.addEventListener('click', async () => { document.getElementById('adminAddMemberForm').style.display = ''; @@ -1434,6 +1437,44 @@ async function toggleUserActive(id, active) { _restoreScroll(el, pos); } catch (e) { UI.toast(e.message, 'error'); } } + +async function showApproveForm(userId, username) { + const form = document.getElementById(`approveForm-${userId}`); + const teamsEl = document.getElementById(`approveTeams-${userId}`); + if (!form || !teamsEl) return; + + // Load teams for checkboxes + teamsEl.innerHTML = 'Loading teams...'; + form.style.display = ''; + try { + const resp = await API.adminListTeams(); + const teams = (resp.data || []).filter(t => t.is_active); + if (teams.length === 0) { + teamsEl.innerHTML = 'No teams — user will be activated without team assignment'; + } else { + teamsEl.innerHTML = '' + + teams.map(t => ``).join(''); + } + } catch (e) { teamsEl.innerHTML = `Could not load teams`; } +} + +function hideApproveForm(userId) { + const form = document.getElementById(`approveForm-${userId}`); + if (form) form.style.display = 'none'; +} + +async function submitApproval(userId) { + const form = document.getElementById(`approveForm-${userId}`); + const teamIds = [...(form?.querySelectorAll('.approve-team-cb:checked') || [])].map(cb => cb.value); + try { + const el = _adminScroll(), pos = el?.scrollTop || 0; + await API.adminToggleActive(userId, true, teamIds, 'member'); + const msg = teamIds.length ? `User activated & assigned to ${teamIds.length} team(s)` : 'User activated'; + UI.toast(msg, 'success'); + await UI.loadAdminUsers(); + _restoreScroll(el, pos); + } catch (e) { UI.toast(e.message, 'error'); } +} async function toggleUserRole(id, role) { try { const el = _adminScroll(), pos = el?.scrollTop || 0; diff --git a/src/js/ui.js b/src/js/ui.js index 7478556..479fa0f 100644 --- a/src/js/ui.js +++ b/src/js/ui.js @@ -388,24 +388,34 @@ const UI = { menu.innerHTML = '
No models loaded
'; UI.setModelValue('', 'No models loaded'); } else { - const presets = App.models.filter(m => m.isPreset); + const globalPresets = App.models.filter(m => m.isPreset && m.presetScope === 'global'); + const teamPresets = App.models.filter(m => m.isPreset && m.presetScope === 'team'); + const personalPresets = App.models.filter(m => m.isPreset && m.presetScope === 'personal'); const models = App.models.filter(m => !m.isPreset); - if (presets.length > 0) { + const addGroup = (label, items) => { + if (items.length === 0) return; const hdr = document.createElement('div'); hdr.className = 'model-dropdown-group'; - hdr.textContent = '⚡ Presets'; + hdr.textContent = label; menu.appendChild(hdr); - presets.forEach(m => menu.appendChild(UI._createDropdownItem(m))); - } + items.forEach(m => menu.appendChild(UI._createDropdownItem(m))); + }; - if (models.length > 0) { - const hdr = document.createElement('div'); - hdr.className = 'model-dropdown-group'; - hdr.textContent = 'Models'; - menu.appendChild(hdr); - models.forEach(m => menu.appendChild(UI._createDropdownItem(m))); - } + addGroup('⚡ Presets', globalPresets); + + // Group team presets by team name + const teamGroups = {}; + teamPresets.forEach(m => { + const tn = m.presetTeamName || 'Team'; + (teamGroups[tn] = teamGroups[tn] || []).push(m); + }); + Object.keys(teamGroups).sort().forEach(tn => { + addGroup(`👥 ${tn}`, teamGroups[tn]); + }); + + addGroup('🔧 My Presets', personalPresets); + addGroup('Models', models); // Restore selection const match = App.models.find(m => m.id === current); @@ -612,6 +622,7 @@ const UI = { } UI.loadProfileIntoSettings(); + UI.loadMyTeams(); UI.switchSettingsTab('general'); openModal('settingsModal'); }, @@ -696,6 +707,31 @@ const UI = { } catch (e) { /* optional */ } }, + async loadMyTeams() { + const section = document.getElementById('settingsTeamsSection'); + const el = document.getElementById('settingsTeamsList'); + if (!section || !el) return; + try { + const resp = await API.listMyTeams(); + const teams = resp.data || []; + if (teams.length === 0) { + section.style.display = 'none'; + return; + } + section.style.display = ''; + el.innerHTML = teams.map(t => ` +
+
+ ${esc(t.name)} + ${t.my_role} +
+ ${t.description ? `
${esc(t.description)}
` : ''} +
${t.member_count} member${t.member_count !== 1 ? 's' : ''}
+
+ `).join(''); + } catch (e) { section.style.display = 'none'; } + }, + // ── Providers ──────────────────────────── async loadProviderList() { @@ -755,19 +791,36 @@ const UI = { try { const resp = await API.adminListUsers(); const users = resp.data || []; - el.innerHTML = users.map(u => ` -
+ el.innerHTML = users.map(u => { + const teamBadges = (u.teams || []).map(t => + `${esc(t.team_name)}` + ).join(' '); + const isPending = !u.is_active; + return ` +
${esc(u.email)}
-
`).join('') || '
No users
'; + + `; + }).join('') || '
No users
'; } catch (e) { el.innerHTML = `
${esc(e.message)}
`; } },