From 5111d595f7019b125f68a55624e9d9f38df2630d Mon Sep 17 00:00:00 2001 From: xcaliber Date: Sun, 22 Feb 2026 01:56:58 +0000 Subject: [PATCH] Changeset 0.8.2 (#45) --- ROADMAP.md | 10 +- VERSION | 2 +- server/database/migrations/017_audit_log.sql | 37 ++++ server/handlers/admin.go | 12 ++ server/handlers/audit.go | 192 +++++++++++++++++++ server/handlers/auth.go | 7 + server/handlers/presets.go | 9 + server/handlers/teams.go | 12 ++ server/main.go | 4 + src/css/styles.css | 12 ++ src/index.html | 65 +++++++ src/js/api.js | 12 ++ src/js/app.js | 89 +++++++++ src/js/ui.js | 185 +++++++++++++++++- 14 files changed, 642 insertions(+), 6 deletions(-) create mode 100644 server/database/migrations/017_audit_log.sql create mode 100644 server/handlers/audit.go diff --git a/ROADMAP.md b/ROADMAP.md index 048f9d0..fa1726b 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -166,7 +166,8 @@ The missing middle tier: scoped administration without system-admin access. - [x] System admin creates teams + assigns first team admin - [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 +- [x] Team admin: create team presets (Settings → Teams tab, self-service UI) +- [ ] Team admin: manage team channels, view team usage **Private Provider Policy** - [x] `is_private` flag on provider configs (marks local/self-hosted endpoints) @@ -179,10 +180,11 @@ The missing middle tier: scoped administration without system-admin access. Required for enterprise and compliance. Cheap to build, expensive to retrofit. **Audit Log** -- [ ] `audit_log` table: actor_id, action, resource_type, resource_id, +- [x] `audit_log` table: actor_id, action, resource_type, resource_id, metadata (jsonb), ip_address, timestamp -- [ ] Every mutating handler inserts audit entry -- [ ] Admin audit viewer (filter by actor, action, resource, date range) +- [x] Every mutating handler inserts audit entry + (user CRUD, team CRUD, member management, presets, auth) +- [x] Admin audit viewer (filter by action, resource type, paginated) - [ ] Team admin sees audit entries scoped to their team **Usage / Cost Tracking** diff --git a/VERSION b/VERSION index 6f4eebd..100435b 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.8.1 +0.8.2 diff --git a/server/database/migrations/017_audit_log.sql b/server/database/migrations/017_audit_log.sql new file mode 100644 index 0000000..809677b --- /dev/null +++ b/server/database/migrations/017_audit_log.sql @@ -0,0 +1,37 @@ +-- ========================================== +-- Migration 017: Audit Log +-- ========================================== +-- Immutable append-only log of all mutating actions. +-- Required for enterprise compliance (SOC2, FedRAMP, HIPAA). +-- ========================================== + +-- Drop stale table if left from a prior partial run +DROP TABLE IF EXISTS audit_log CASCADE; + +CREATE TABLE audit_log ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + actor_id UUID REFERENCES users(id) ON DELETE SET NULL, + action VARCHAR(100) NOT NULL, -- e.g. 'user.create', 'team.add_member' + resource_type VARCHAR(50) NOT NULL, -- e.g. 'user', 'team', 'preset', 'channel' + resource_id VARCHAR(255), -- UUID or identifier of affected resource + metadata JSONB DEFAULT '{}'::jsonb, -- action-specific details + ip_address VARCHAR(45), -- IPv4 or IPv6 + user_agent TEXT DEFAULT '', + created_at TIMESTAMPTZ DEFAULT NOW() +); + +-- Time-range queries (admin viewer, compliance exports) +CREATE INDEX idx_audit_log_created ON audit_log(created_at DESC); + +-- Filter by actor +CREATE INDEX idx_audit_log_actor ON audit_log(actor_id) WHERE actor_id IS NOT NULL; + +-- Filter by resource +CREATE INDEX idx_audit_log_resource ON audit_log(resource_type, resource_id); + +-- Filter by action +CREATE INDEX idx_audit_log_action ON audit_log(action); + +COMMENT ON TABLE audit_log IS 'Immutable audit trail of all mutating operations'; +COMMENT ON COLUMN audit_log.action IS 'Dotted action name: resource.verb (e.g. user.create, team.add_member)'; +COMMENT ON COLUMN audit_log.metadata IS 'Action-specific context: old/new values, affected fields, etc.'; diff --git a/server/handlers/admin.go b/server/handlers/admin.go index e2983c1..3ef9030 100644 --- a/server/handlers/admin.go +++ b/server/handlers/admin.go @@ -189,6 +189,9 @@ func (h *AdminHandler) CreateUser(c *gin.Context) { } c.JSON(http.StatusCreated, user) + AuditLog(c, "user.create", "user", user.ID, map[string]interface{}{ + "username": req.Username, "email": req.Email, "role": req.Role, + }) } // ── Reset Password (admin) ────────────────── @@ -258,6 +261,7 @@ func (h *AdminHandler) UpdateUserRole(c *gin.Context) { } c.JSON(http.StatusOK, gin.H{"message": "role updated", "role": req.Role}) + AuditLog(c, "user.role_change", "user", targetID, map[string]interface{}{"role": req.Role}) } // ── Toggle User Active ────────────────────── @@ -316,6 +320,13 @@ func (h *AdminHandler) ToggleUserActive(c *gin.Context) { "is_active": req.IsActive, "teams_assigned": teamsAssigned, }) + action := "user.activate" + if !req.IsActive { + action = "user.deactivate" + } + AuditLog(c, action, "user", targetID, map[string]interface{}{ + "is_active": req.IsActive, "teams_assigned": teamsAssigned, + }) } // ── Delete User ───────────────────────────── @@ -341,6 +352,7 @@ func (h *AdminHandler) DeleteUser(c *gin.Context) { } c.JSON(http.StatusOK, gin.H{"message": "user deleted"}) + AuditLog(c, "user.delete", "user", targetID, nil) } // ── Public Settings (for any authenticated user) ── diff --git a/server/handlers/audit.go b/server/handlers/audit.go new file mode 100644 index 0000000..9ecff20 --- /dev/null +++ b/server/handlers/audit.go @@ -0,0 +1,192 @@ +package handlers + +import ( + "database/sql" + "encoding/json" + "net/http" + "strconv" + + "github.com/gin-gonic/gin" + + "git.gobha.me/xcaliber/chat-switchboard/database" +) + +// AuditLog inserts an audit entry. Called from mutating handlers. +// Non-blocking: errors are logged but don't break the caller. +func AuditLog(c *gin.Context, action, resourceType, resourceID string, metadata map[string]interface{}) { + if database.DB == nil { + return + } + + actorID := getUserID(c) + if actorID == "" { + return + } + + ip := c.ClientIP() + ua := c.GetHeader("User-Agent") + + metaJSON := "{}" + if metadata != nil { + if b, err := json.Marshal(metadata); err == nil { + metaJSON = string(b) + } + } + + _, _ = database.DB.Exec(` + INSERT INTO audit_log (actor_id, action, resource_type, resource_id, metadata, ip_address, user_agent) + VALUES ($1, $2, $3, $4, $5::jsonb, $6, $7) + `, actorID, action, resourceType, resourceID, metaJSON, ip, ua) +} + +// AuditLogAnon inserts an audit entry without a gin context (e.g. system actions). +func AuditLogAnon(action, resourceType, resourceID string, metadata map[string]interface{}) { + if database.DB == nil { + return + } + metaJSON := "{}" + if metadata != nil { + if b, err := json.Marshal(metadata); err == nil { + metaJSON = string(b) + } + } + _, _ = database.DB.Exec(` + INSERT INTO audit_log (action, resource_type, resource_id, metadata) + VALUES ($1, $2, $3, $4::jsonb) + `, action, resourceType, resourceID, metaJSON) +} + +// AuditLogWithActor inserts an audit entry with an explicit actor ID (e.g. pre-auth flows). +func AuditLogWithActor(actorID string, c *gin.Context, action, resourceType, resourceID string, metadata map[string]interface{}) { + if database.DB == nil || actorID == "" { + return + } + ip := c.ClientIP() + ua := c.GetHeader("User-Agent") + metaJSON := "{}" + if metadata != nil { + if b, err := json.Marshal(metadata); err == nil { + metaJSON = string(b) + } + } + _, _ = database.DB.Exec(` + INSERT INTO audit_log (actor_id, action, resource_type, resource_id, metadata, ip_address, user_agent) + VALUES ($1, $2, $3, $4, $5::jsonb, $6, $7) + `, actorID, action, resourceType, resourceID, metaJSON, ip, ua) +} + +// ── Admin Audit Viewer ────────────────────── + +type auditEntry struct { + ID string `json:"id"` + ActorID *string `json:"actor_id"` + ActorName *string `json:"actor_name"` + Action string `json:"action"` + ResourceType string `json:"resource_type"` + ResourceID *string `json:"resource_id"` + Metadata string `json:"metadata"` + IPAddress *string `json:"ip_address"` + CreatedAt string `json:"created_at"` +} + +// ListAuditLog returns paginated audit entries with optional filters. +// GET /api/v1/admin/audit?page=1&per_page=50&action=user.create&actor_id=...&resource_type=... +func (h *AdminHandler) ListAuditLog(c *gin.Context) { + page, perPage, offset := parsePagination(c) + + // Build filter clauses + where := "WHERE 1=1" + args := []interface{}{} + argN := 1 + + if action := c.Query("action"); action != "" { + where += " AND al.action = $" + strconv.Itoa(argN) + args = append(args, action) + argN++ + } + if actorID := c.Query("actor_id"); actorID != "" { + where += " AND al.actor_id = $" + strconv.Itoa(argN) + args = append(args, actorID) + argN++ + } + if rt := c.Query("resource_type"); rt != "" { + where += " AND al.resource_type = $" + strconv.Itoa(argN) + args = append(args, rt) + argN++ + } + + // Count + var total int + countArgs := make([]interface{}, len(args)) + copy(countArgs, args) + err := database.DB.QueryRow(`SELECT COUNT(*) FROM audit_log al `+where, countArgs...).Scan(&total) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "count failed"}) + return + } + + // Query + query := ` + SELECT al.id, al.actor_id, COALESCE(u.username, '') as actor_name, + al.action, al.resource_type, al.resource_id, + COALESCE(al.metadata::text, '{}'), al.ip_address, al.created_at + FROM audit_log al + LEFT JOIN users u ON al.actor_id = u.id + ` + where + ` + ORDER BY al.created_at DESC + LIMIT $` + strconv.Itoa(argN) + ` OFFSET $` + strconv.Itoa(argN+1) + args = append(args, perPage, offset) + + rows, err := database.DB.Query(query, args...) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "query failed"}) + return + } + defer rows.Close() + + entries := make([]auditEntry, 0) + for rows.Next() { + var e auditEntry + var actorName sql.NullString + if err := rows.Scan(&e.ID, &e.ActorID, &actorName, &e.Action, + &e.ResourceType, &e.ResourceID, &e.Metadata, &e.IPAddress, &e.CreatedAt); err != nil { + continue + } + if actorName.Valid { + e.ActorName = &actorName.String + } + entries = append(entries, e) + } + + c.JSON(http.StatusOK, gin.H{ + "data": entries, + "total": total, + "page": page, + "per_page": perPage, + }) +} + +// ListAuditActions returns distinct action names for filter dropdowns. +// GET /api/v1/admin/audit/actions +func (h *AdminHandler) ListAuditActions(c *gin.Context) { + rows, err := database.DB.Query(` + SELECT DISTINCT action FROM audit_log ORDER BY action ASC + `) + if err != nil { + c.JSON(http.StatusOK, gin.H{"actions": []string{}}) + return + } + defer rows.Close() + + var actions []string + for rows.Next() { + var a string + if rows.Scan(&a) == nil { + actions = append(actions, a) + } + } + if actions == nil { + actions = []string{} + } + c.JSON(http.StatusOK, gin.H{"actions": actions}) +} diff --git a/server/handlers/auth.go b/server/handlers/auth.go index e964a03..6a184ca 100644 --- a/server/handlers/auth.go +++ b/server/handlers/auth.go @@ -153,6 +153,9 @@ func (h *AuthHandler) Register(c *gin.Context) { "message": "Account created and pending admin approval", "pending": true, }) + AuditLogWithActor(user.ID, c, "user.register", "user", user.ID, map[string]interface{}{ + "username": req.Username, "pending": true, + }) return } @@ -164,6 +167,9 @@ func (h *AuthHandler) Register(c *gin.Context) { } c.JSON(http.StatusCreated, resp) + AuditLogWithActor(user.ID, c, "user.register", "user", user.ID, map[string]interface{}{ + "username": req.Username, "pending": false, + }) } // IsRegistrationEnabled checks the global_settings table. @@ -307,6 +313,7 @@ func (h *AuthHandler) Login(c *gin.Context) { } c.JSON(http.StatusOK, resp) + AuditLogWithActor(user.ID, c, "user.login", "user", user.ID, nil) } // ── Refresh ───────────────────────────────── diff --git a/server/handlers/presets.go b/server/handlers/presets.go index a9f51a6..432005b 100644 --- a/server/handlers/presets.go +++ b/server/handlers/presets.go @@ -373,6 +373,9 @@ func (h *PresetHandler) CreateAdminPreset(c *gin.Context) { } c.JSON(http.StatusCreated, gin.H{"id": id}) + AuditLog(c, "preset.create", "preset", id, map[string]interface{}{ + "name": req.Name, "scope": "global", "base_model": req.BaseModelID, + }) } // UpdateAdminPreset updates any preset (admin can edit global and personal). @@ -613,6 +616,9 @@ func (h *PresetHandler) CreateTeamPreset(c *gin.Context) { } c.JSON(http.StatusCreated, gin.H{"id": id}) + AuditLog(c, "preset.create", "preset", id, map[string]interface{}{ + "name": req.Name, "scope": "team", "team_id": teamID, "base_model": req.BaseModelID, + }) } // DeleteTeamPreset deletes a team-scoped preset. @@ -634,4 +640,7 @@ func (h *PresetHandler) DeleteTeamPreset(c *gin.Context) { } c.JSON(http.StatusOK, gin.H{"ok": true}) + AuditLog(c, "preset.delete", "preset", presetID, map[string]interface{}{ + "scope": "team", "team_id": teamID, + }) } diff --git a/server/handlers/teams.go b/server/handlers/teams.go index 7f28850..d73e58e 100644 --- a/server/handlers/teams.go +++ b/server/handlers/teams.go @@ -138,6 +138,7 @@ func (h *TeamHandler) CreateTeam(c *gin.Context) { } c.JSON(http.StatusCreated, gin.H{"id": id, "name": req.Name}) + AuditLog(c, "team.create", "team", id, map[string]interface{}{"name": req.Name}) } // ── Admin: Get Team ───────────────────────── @@ -246,6 +247,7 @@ func (h *TeamHandler) UpdateTeam(c *gin.Context) { } c.JSON(http.StatusOK, gin.H{"ok": true}) + AuditLog(c, "team.update", "team", teamID, nil) } // ── Admin: Delete Team ────────────────────── @@ -264,6 +266,7 @@ func (h *TeamHandler) DeleteTeam(c *gin.Context) { } c.JSON(http.StatusOK, gin.H{"ok": true}) + AuditLog(c, "team.delete", "team", teamID, nil) } // ── Members: List ─────────────────────────── @@ -351,6 +354,9 @@ func (h *TeamHandler) AddMember(c *gin.Context) { } c.JSON(http.StatusCreated, gin.H{"id": id}) + AuditLog(c, "team.add_member", "team", getTeamID(c), map[string]interface{}{ + "user_id": req.UserID, "role": req.Role, + }) } // ── Members: Update Role ──────────────────── @@ -377,6 +383,9 @@ func (h *TeamHandler) UpdateMember(c *gin.Context) { } c.JSON(http.StatusOK, gin.H{"ok": true}) + AuditLog(c, "team.update_member", "team", getTeamID(c), map[string]interface{}{ + "member_id": memberID, "role": req.Role, + }) } // ── Members: Remove ───────────────────────── @@ -395,6 +404,9 @@ func (h *TeamHandler) RemoveMember(c *gin.Context) { } c.JSON(http.StatusOK, gin.H{"ok": true}) + AuditLog(c, "team.remove_member", "team", getTeamID(c), map[string]interface{}{ + "member_id": memberID, + }) } // ── User: My Teams ────────────────────────── diff --git a/server/main.go b/server/main.go index 649049b..52739c1 100644 --- a/server/main.go +++ b/server/main.go @@ -232,6 +232,10 @@ func main() { // Teams (admin) teamAdm := handlers.NewTeamHandler() admin.GET("/teams", teamAdm.ListTeams) + + // Audit log + admin.GET("/audit", adm.ListAuditLog) + admin.GET("/audit/actions", adm.ListAuditActions) admin.POST("/teams", teamAdm.CreateTeam) admin.GET("/teams/:id", teamAdm.GetTeam) admin.PUT("/teams/:id", teamAdm.UpdateTeam) diff --git a/src/css/styles.css b/src/css/styles.css index a4b456e..f21b5fe 100644 --- a/src/css/styles.css +++ b/src/css/styles.css @@ -1044,6 +1044,18 @@ button { font-family: var(--font); cursor: pointer; } .stat-card .stat-value { font-size: 1.6rem; font-weight: 700; color: var(--text); line-height: 1.2; } .stat-card .stat-label { font-size: 0.78rem; color: var(--text-3); margin-top: 4px; text-transform: uppercase; letter-spacing: 0.04em; } +/* Audit log */ +.audit-row { padding: 6px 0; border-bottom: 1px solid var(--border); font-size: 13px; } +.audit-row:last-child { border-bottom: none; } +.audit-main { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; } +.audit-action { font-weight: 600; color: var(--accent); font-family: var(--font-mono, monospace); font-size: 12px; } +.audit-actor { color: var(--text); } +.audit-resource { color: var(--text-3); font-family: var(--font-mono, monospace); font-size: 11px; } +.audit-meta { display: flex; gap: 10px; margin-top: 2px; font-size: 11px; color: var(--text-3); flex-wrap: wrap; } +.audit-details { color: var(--text-2); } +.audit-ip { font-family: var(--font-mono, monospace); } +.pagination-row { display: flex; align-items: center; justify-content: center; gap: 12px; } + /* Banner preview */ .banner-preview { margin-top: 0.75rem; padding: 6px 16px; text-align: center; diff --git a/src/index.html b/src/index.html index 3def882..55a6fbc 100644 --- a/src/index.html +++ b/src/index.html @@ -224,6 +224,7 @@ + + + @@ -324,6 +368,7 @@ + diff --git a/src/js/api.js b/src/js/api.js index 1fe8611..79d2313 100644 --- a/src/js/api.js +++ b/src/js/api.js @@ -292,6 +292,18 @@ const API = { // ── Admin Teams ───────────────────────── adminListTeams() { return this._get('/api/v1/admin/teams'); }, + + // ── Admin Audit ───────────────────────── + adminListAudit(params) { + const q = new URLSearchParams(); + if (params?.page) q.set('page', params.page); + if (params?.per_page) q.set('per_page', params.per_page); + if (params?.action) q.set('action', params.action); + if (params?.actor_id) q.set('actor_id', params.actor_id); + if (params?.resource_type) q.set('resource_type', params.resource_type); + return this._get(`/api/v1/admin/audit?${q}`); + }, + adminListAuditActions() { return this._get('/api/v1/admin/audit/actions'); }, adminCreateTeam(name, description) { return this._post('/api/v1/admin/teams', { name, description }); }, adminGetTeam(id) { return this._get(`/api/v1/admin/teams/${id}`); }, adminUpdateTeam(id, updates) { return this._put(`/api/v1/admin/teams/${id}`, updates); }, diff --git a/src/js/app.js b/src/js/app.js index 70b4f9f..812105d 100644 --- a/src/js/app.js +++ b/src/js/app.js @@ -1119,6 +1119,58 @@ function initListeners() { } catch (e) { UI.toast(e.message, 'error'); } }); + // Settings — Team management (team admin self-service) + document.getElementById('settingsTeamBackBtn')?.addEventListener('click', () => { + UI.loadTeamsTab(); + }); + document.getElementById('settingsTeamAddMemberBtn')?.addEventListener('click', async () => { + document.getElementById('settingsTeamAddMember').style.display = ''; + // Load available users (requires sys-admin; team admins use their own knowledge) + const sel = document.getElementById('settingsTeamMemberUser'); + sel.innerHTML = ''; + try { + const resp = await API.teamListMembers(UI._managingTeamId); + const existingIds = new Set((resp.data || []).map(m => m.user_id)); + // Team admins can't list all users — provide an email input fallback + sel.innerHTML = ''; + } catch (e) { sel.innerHTML = ''; } + }); + document.getElementById('settingsTeamCancelMember')?.addEventListener('click', () => { + document.getElementById('settingsTeamAddMember').style.display = 'none'; + }); + document.getElementById('settingsTeamAddPresetBtn')?.addEventListener('click', () => { + document.getElementById('settingsTeamAddPreset').style.display = ''; + UI.loadTeamPresetModelDropdown(); + }); + document.getElementById('settingsTeamCancelPreset')?.addEventListener('click', () => { + document.getElementById('settingsTeamAddPreset').style.display = 'none'; + }); + document.getElementById('settingsTeamCreatePresetBtn')?.addEventListener('click', async () => { + const teamId = UI._managingTeamId; + const name = document.getElementById('settingsTeamPresetName').value.trim(); + const baseModelId = document.getElementById('settingsTeamPresetModel').value; + if (!name) return UI.toast('Name required', 'error'); + if (!baseModelId) return UI.toast('Select a base model', 'error'); + const preset = { + name, + base_model_id: baseModelId, + description: document.getElementById('settingsTeamPresetDesc').value.trim(), + icon: document.getElementById('settingsTeamPresetIcon').value.trim(), + system_prompt: document.getElementById('settingsTeamPresetPrompt').value, + temperature: parseFloat(document.getElementById('settingsTeamPresetTemp').value) || undefined, + max_tokens: parseInt(document.getElementById('settingsTeamPresetMaxTokens').value) || undefined, + }; + try { + await API.teamCreatePreset(teamId, preset); + document.getElementById('settingsTeamAddPreset').style.display = 'none'; + ['settingsTeamPresetName','settingsTeamPresetDesc','settingsTeamPresetIcon','settingsTeamPresetPrompt','settingsTeamPresetTemp','settingsTeamPresetMaxTokens'] + .forEach(id => { const el = document.getElementById(id); if (el) el.value = ''; }); + UI.toast('Team preset created'); + await UI.loadTeamManagePresets(teamId); + await fetchModels(); // refresh model selector + } catch (e) { UI.toast(e.message, 'error'); } + }); + // Admin — banner controls document.getElementById('adminBannerEnabled')?.addEventListener('change', (e) => { document.getElementById('bannerConfigFields').style.display = e.target.checked ? '' : 'none'; @@ -1143,6 +1195,13 @@ function initListeners() { document.getElementById('adminBannerBgHex')?.addEventListener('input', (e) => { if (/^#[0-9a-f]{6}$/i.test(e.target.value)) { document.getElementById('adminBannerBg').value = e.target.value; UI.updateBannerPreview(); }}); document.getElementById('adminBannerFgHex')?.addEventListener('input', (e) => { if (/^#[0-9a-f]{6}$/i.test(e.target.value)) { document.getElementById('adminBannerFg').value = e.target.value; UI.updateBannerPreview(); }}); + // Admin — audit log + document.getElementById('auditRefreshBtn')?.addEventListener('click', () => UI.loadAuditLog(1)); + document.getElementById('auditFilterAction')?.addEventListener('change', () => UI.loadAuditLog(1)); + document.getElementById('auditFilterResource')?.addEventListener('change', () => UI.loadAuditLog(1)); + document.getElementById('auditPrevBtn')?.addEventListener('click', () => { if (UI._auditPage > 1) UI.loadAuditLog(UI._auditPage - 1); }); + document.getElementById('auditNextBtn')?.addEventListener('click', () => UI.loadAuditLog(UI._auditPage + 1)); + // Input const input = document.getElementById('messageInput'); input.addEventListener('keydown', (e) => { @@ -1722,6 +1781,36 @@ async function removeTeamMember(teamId, memberId, email) { } catch (e) { UI.toast(e.message, 'error'); } } +// Settings-side team management (uses team admin API, not sys-admin) +async function settingsUpdateTeamMember(teamId, memberId, role) { + try { + await API.teamUpdateMember(teamId, memberId, role); + UI.toast('Role updated'); + } catch (e) { + UI.toast(e.message, 'error'); + await UI.loadTeamManageMembers(teamId); + } +} + +async function settingsRemoveTeamMember(teamId, memberId, email) { + if (!confirm(`Remove ${email} from team?`)) return; + try { + await API.teamRemoveMember(teamId, memberId); + UI.toast('Member removed'); + await UI.loadTeamManageMembers(teamId); + } catch (e) { UI.toast(e.message, 'error'); } +} + +async function settingsDeleteTeamPreset(teamId, presetId, name) { + if (!confirm(`Delete team preset "${name}"?`)) return; + try { + await API.teamDeletePreset(teamId, presetId); + UI.toast('Preset deleted'); + await UI.loadTeamManagePresets(teamId); + await fetchModels(); + } catch (e) { UI.toast(e.message, 'error'); } +} + // ── Notes Panel ───────────────────────────── var _editingNoteId = null; diff --git a/src/js/ui.js b/src/js/ui.js index 479fa0f..b233042 100644 --- a/src/js/ui.js +++ b/src/js/ui.js @@ -640,6 +640,7 @@ const UI = { } if (tab === 'models') UI.loadUserModels(); if (tab === 'appearance') UI.loadAppearanceSettings(); + if (tab === 'teams') UI.loadTeamsTab(); }, // ── Appearance Settings ───────────────── @@ -710,10 +711,17 @@ const UI = { async loadMyTeams() { const section = document.getElementById('settingsTeamsSection'); const el = document.getElementById('settingsTeamsList'); + const tabBtn = document.getElementById('settingsTeamsTabBtn'); if (!section || !el) return; try { const resp = await API.listMyTeams(); const teams = resp.data || []; + UI._myTeams = teams; + + // Show/hide the Teams tab for team admins + const isTeamAdmin = teams.some(t => t.my_role === 'admin'); + if (tabBtn) tabBtn.style.display = isTeamAdmin ? '' : 'none'; + if (teams.length === 0) { section.style.display = 'none'; return; @@ -729,7 +737,102 @@ const UI = {
${t.member_count} member${t.member_count !== 1 ? 's' : ''}
`).join(''); - } catch (e) { section.style.display = 'none'; } + } catch (e) { section.style.display = 'none'; if (tabBtn) tabBtn.style.display = 'none'; } + }, + + _myTeams: [], + _managingTeamId: null, + + loadTeamsTab() { + const picker = document.getElementById('settingsTeamPicker'); + const manage = document.getElementById('settingsTeamManage'); + if (!picker) return; + manage.style.display = 'none'; + picker.style.display = ''; + + const adminTeams = UI._myTeams.filter(t => t.my_role === 'admin'); + if (adminTeams.length === 0) { + picker.innerHTML = '
You are not an admin of any teams.
'; + return; + } + picker.innerHTML = adminTeams.map(t => ` +
+
+ ${esc(t.name)} + admin + Manage → +
+ ${t.description ? `
${esc(t.description)}
` : ''} +
${t.member_count} member${t.member_count !== 1 ? 's' : ''}
+
+ `).join(''); + }, + + async openTeamManage(teamId, teamName) { + UI._managingTeamId = teamId; + document.getElementById('settingsTeamPicker').style.display = 'none'; + const manage = document.getElementById('settingsTeamManage'); + manage.style.display = ''; + document.getElementById('settingsTeamManageName').textContent = teamName; + document.getElementById('settingsTeamAddMember').style.display = 'none'; + document.getElementById('settingsTeamAddPreset').style.display = 'none'; + await Promise.all([UI.loadTeamManageMembers(teamId), UI.loadTeamManagePresets(teamId)]); + }, + + async loadTeamManageMembers(teamId) { + const el = document.getElementById('settingsTeamMembers'); + el.innerHTML = '
Loading...
'; + try { + const resp = await API.teamListMembers(teamId); + const members = resp.data || []; + el.innerHTML = members.map(m => ` +
+ + +
+ `).join('') || '
No members
'; + } catch (e) { el.innerHTML = `
${esc(e.message)}
`; } + }, + + async loadTeamManagePresets(teamId) { + const el = document.getElementById('settingsTeamPresets'); + el.innerHTML = '
Loading...
'; + try { + const resp = await API.teamListPresets(teamId); + const presets = resp.presets || []; + el.innerHTML = presets.map(p => { + const icon = p.icon || ''; + return `
+
+ ${icon ? icon + ' ' : ''}${esc(p.name)} +
${esc(p.base_model_id)}
+
+ +
`; + }).join('') || '
No team presets — create one to give your team preconfigured models
'; + } catch (e) { el.innerHTML = `
${esc(e.message)}
`; } + }, + + async loadTeamPresetModelDropdown() { + const sel = document.getElementById('settingsTeamPresetModel'); + if (!sel) return; + sel.innerHTML = ''; + const models = App.models.filter(m => !m.isPreset); + models.forEach(m => { + const opt = document.createElement('option'); + opt.value = m.baseModelId || m.id; + opt.textContent = (m.name || m.id) + (m.provider ? ` (${m.provider})` : ''); + sel.appendChild(opt); + }); }, // ── Providers ──────────────────────────── @@ -778,6 +881,7 @@ const UI = { if (tab === 'users') await this.loadAdminUsers(); if (tab === 'stats') await this.loadAdminStats(); + if (tab === 'audit') await this.loadAuditLog(); if (tab === 'providers') await this.loadAdminProviders(); if (tab === 'models') await this.loadAdminModels(); if (tab === 'presets') await this.loadAdminPresets(); @@ -840,6 +944,85 @@ const UI = { } catch (e) { el.innerHTML = `
${esc(e.message)}
`; } }, + _auditPage: 1, + _auditPerPage: 30, + + async loadAuditLog(page) { + if (page) UI._auditPage = page; + const el = document.getElementById('adminAuditList'); + el.innerHTML = '
Loading...
'; + + // Populate action filter on first load + const actionSel = document.getElementById('auditFilterAction'); + if (actionSel && actionSel.options.length <= 1) { + try { + const resp = await API.adminListAuditActions(); + (resp.actions || []).forEach(a => { + const opt = document.createElement('option'); + opt.value = a; + opt.textContent = a; + actionSel.appendChild(opt); + }); + } catch (e) { /* optional */ } + } + + const params = { + page: UI._auditPage, + per_page: UI._auditPerPage, + action: document.getElementById('auditFilterAction')?.value || '', + resource_type: document.getElementById('auditFilterResource')?.value || '', + }; + + try { + const resp = await API.adminListAudit(params); + const entries = resp.data || []; + const total = resp.total || 0; + const totalPages = Math.ceil(total / UI._auditPerPage); + + if (entries.length === 0) { + el.innerHTML = '
No audit entries found
'; + } else { + el.innerHTML = entries.map(e => { + const ts = new Date(e.created_at); + const timeStr = ts.toLocaleDateString() + ' ' + ts.toLocaleTimeString([], {hour:'2-digit', minute:'2-digit'}); + const actor = e.actor_name || 'system'; + const meta = UI._formatAuditMeta(e.metadata); + return `
+
+ ${esc(e.action)} + ${esc(actor)} + ${esc(e.resource_type)}${e.resource_id ? ':' + esc(e.resource_id).slice(0,8) : ''} +
+
+ ${meta ? `${meta}` : ''} + ${timeStr} + ${e.ip_address ? `${esc(e.ip_address)}` : ''} +
+
`; + }).join(''); + } + + // Pagination + const pgEl = document.getElementById('auditPagination'); + if (totalPages > 1) { + pgEl.style.display = 'flex'; + document.getElementById('auditPageInfo').textContent = `Page ${UI._auditPage} of ${totalPages} (${total} entries)`; + document.getElementById('auditPrevBtn').disabled = UI._auditPage <= 1; + document.getElementById('auditNextBtn').disabled = UI._auditPage >= totalPages; + } else { + pgEl.style.display = 'none'; + } + } catch (e) { el.innerHTML = `
${esc(e.message)}
`; } + }, + + _formatAuditMeta(metaStr) { + try { + const m = typeof metaStr === 'string' ? JSON.parse(metaStr) : metaStr; + if (!m || Object.keys(m).length === 0) return ''; + return Object.entries(m).map(([k,v]) => `${esc(k)}=${esc(String(v))}`).join(' · '); + } catch { return ''; } + }, + async loadAdminProviders(quiet) { const el = document.getElementById('adminProviderList'); if (!quiet) el.innerHTML = '
Loading...
';