Changeset 0.8.2 (#45)

This commit is contained in:
2026-02-22 01:56:58 +00:00
parent c0d95fd7f5
commit 5111d595f7
14 changed files with 642 additions and 6 deletions

View File

@@ -166,7 +166,8 @@ The missing middle tier: scoped administration without system-admin access.
- [x] System admin creates teams + assigns first team admin - [x] System admin creates teams + assigns first team admin
- [x] Team admin self-manages: add/remove members - [x] Team admin self-manages: add/remove members
(RequireTeamAdmin middleware, scoped /teams/:teamId routes) (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** **Private Provider Policy**
- [x] `is_private` flag on provider configs (marks local/self-hosted endpoints) - [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. Required for enterprise and compliance. Cheap to build, expensive to retrofit.
**Audit Log** **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 metadata (jsonb), ip_address, timestamp
- [ ] Every mutating handler inserts audit entry - [x] Every mutating handler inserts audit entry
- [ ] Admin audit viewer (filter by actor, action, resource, date range) (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 - [ ] Team admin sees audit entries scoped to their team
**Usage / Cost Tracking** **Usage / Cost Tracking**

View File

@@ -1 +1 @@
0.8.1 0.8.2

View File

@@ -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.';

View File

@@ -189,6 +189,9 @@ func (h *AdminHandler) CreateUser(c *gin.Context) {
} }
c.JSON(http.StatusCreated, user) 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) ────────────────── // ── 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}) 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 ────────────────────── // ── Toggle User Active ──────────────────────
@@ -316,6 +320,13 @@ func (h *AdminHandler) ToggleUserActive(c *gin.Context) {
"is_active": req.IsActive, "is_active": req.IsActive,
"teams_assigned": teamsAssigned, "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 ───────────────────────────── // ── Delete User ─────────────────────────────
@@ -341,6 +352,7 @@ func (h *AdminHandler) DeleteUser(c *gin.Context) {
} }
c.JSON(http.StatusOK, gin.H{"message": "user deleted"}) c.JSON(http.StatusOK, gin.H{"message": "user deleted"})
AuditLog(c, "user.delete", "user", targetID, nil)
} }
// ── Public Settings (for any authenticated user) ── // ── Public Settings (for any authenticated user) ──

192
server/handlers/audit.go Normal file
View File

@@ -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})
}

View File

@@ -153,6 +153,9 @@ func (h *AuthHandler) Register(c *gin.Context) {
"message": "Account created and pending admin approval", "message": "Account created and pending admin approval",
"pending": true, "pending": true,
}) })
AuditLogWithActor(user.ID, c, "user.register", "user", user.ID, map[string]interface{}{
"username": req.Username, "pending": true,
})
return return
} }
@@ -164,6 +167,9 @@ func (h *AuthHandler) Register(c *gin.Context) {
} }
c.JSON(http.StatusCreated, resp) 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. // IsRegistrationEnabled checks the global_settings table.
@@ -307,6 +313,7 @@ func (h *AuthHandler) Login(c *gin.Context) {
} }
c.JSON(http.StatusOK, resp) c.JSON(http.StatusOK, resp)
AuditLogWithActor(user.ID, c, "user.login", "user", user.ID, nil)
} }
// ── Refresh ───────────────────────────────── // ── Refresh ─────────────────────────────────

View File

@@ -373,6 +373,9 @@ func (h *PresetHandler) CreateAdminPreset(c *gin.Context) {
} }
c.JSON(http.StatusCreated, gin.H{"id": id}) 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). // 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}) 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. // 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}) c.JSON(http.StatusOK, gin.H{"ok": true})
AuditLog(c, "preset.delete", "preset", presetID, map[string]interface{}{
"scope": "team", "team_id": teamID,
})
} }

View File

@@ -138,6 +138,7 @@ func (h *TeamHandler) CreateTeam(c *gin.Context) {
} }
c.JSON(http.StatusCreated, gin.H{"id": id, "name": req.Name}) 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 ───────────────────────── // ── Admin: Get Team ─────────────────────────
@@ -246,6 +247,7 @@ func (h *TeamHandler) UpdateTeam(c *gin.Context) {
} }
c.JSON(http.StatusOK, gin.H{"ok": true}) c.JSON(http.StatusOK, gin.H{"ok": true})
AuditLog(c, "team.update", "team", teamID, nil)
} }
// ── Admin: Delete Team ────────────────────── // ── Admin: Delete Team ──────────────────────
@@ -264,6 +266,7 @@ func (h *TeamHandler) DeleteTeam(c *gin.Context) {
} }
c.JSON(http.StatusOK, gin.H{"ok": true}) c.JSON(http.StatusOK, gin.H{"ok": true})
AuditLog(c, "team.delete", "team", teamID, nil)
} }
// ── Members: List ─────────────────────────── // ── Members: List ───────────────────────────
@@ -351,6 +354,9 @@ func (h *TeamHandler) AddMember(c *gin.Context) {
} }
c.JSON(http.StatusCreated, gin.H{"id": id}) 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 ──────────────────── // ── Members: Update Role ────────────────────
@@ -377,6 +383,9 @@ func (h *TeamHandler) UpdateMember(c *gin.Context) {
} }
c.JSON(http.StatusOK, gin.H{"ok": true}) 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 ───────────────────────── // ── Members: Remove ─────────────────────────
@@ -395,6 +404,9 @@ func (h *TeamHandler) RemoveMember(c *gin.Context) {
} }
c.JSON(http.StatusOK, gin.H{"ok": true}) 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 ────────────────────────── // ── User: My Teams ──────────────────────────

View File

@@ -232,6 +232,10 @@ func main() {
// Teams (admin) // Teams (admin)
teamAdm := handlers.NewTeamHandler() teamAdm := handlers.NewTeamHandler()
admin.GET("/teams", teamAdm.ListTeams) admin.GET("/teams", teamAdm.ListTeams)
// Audit log
admin.GET("/audit", adm.ListAuditLog)
admin.GET("/audit/actions", adm.ListAuditActions)
admin.POST("/teams", teamAdm.CreateTeam) admin.POST("/teams", teamAdm.CreateTeam)
admin.GET("/teams/:id", teamAdm.GetTeam) admin.GET("/teams/:id", teamAdm.GetTeam)
admin.PUT("/teams/:id", teamAdm.UpdateTeam) admin.PUT("/teams/:id", teamAdm.UpdateTeam)

View File

@@ -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-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; } .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 */
.banner-preview { .banner-preview {
margin-top: 0.75rem; padding: 6px 16px; text-align: center; margin-top: 0.75rem; padding: 6px 16px; text-align: center;

View File

@@ -224,6 +224,7 @@
<button class="settings-tab" data-stab="appearance" onclick="UI.switchSettingsTab('appearance')">Appearance</button> <button class="settings-tab" data-stab="appearance" onclick="UI.switchSettingsTab('appearance')">Appearance</button>
<button class="settings-tab" data-stab="providers" onclick="UI.switchSettingsTab('providers')">Providers</button> <button class="settings-tab" data-stab="providers" onclick="UI.switchSettingsTab('providers')">Providers</button>
<button class="settings-tab" data-stab="models" onclick="UI.switchSettingsTab('models')">Models</button> <button class="settings-tab" data-stab="models" onclick="UI.switchSettingsTab('models')">Models</button>
<button class="settings-tab" data-stab="teams" onclick="UI.switchSettingsTab('teams')" id="settingsTeamsTabBtn" style="display:none">Teams</button>
</div> </div>
<div class="modal-body"> <div class="modal-body">
<!-- General Tab --> <!-- General Tab -->
@@ -307,6 +308,49 @@
<p class="section-hint">Models available from your providers and global configs.</p> <p class="section-hint">Models available from your providers and global configs.</p>
<div id="userModelList" class="model-list-grid"><div class="empty-hint">Loading models...</div></div> <div id="userModelList" class="model-list-grid"><div class="empty-hint">Loading models...</div></div>
</div> </div>
<!-- Teams Tab (visible to team admins) -->
<div class="settings-tab-content" id="settingsTeamsTab" style="display:none">
<div id="settingsTeamPicker"></div>
<div id="settingsTeamManage" style="display:none">
<button class="notes-back-btn" id="settingsTeamBackBtn">← Back to teams</button>
<h3 id="settingsTeamManageName" style="margin:8px 0 4px;font-size:15px"></h3>
<!-- Members -->
<section class="settings-section">
<h4 style="font-size:13px;margin-bottom:6px">Members</h4>
<div id="settingsTeamMembers"></div>
<div id="settingsTeamAddMember" style="display:none;margin-top:8px" class="admin-inline-form">
<div class="form-row">
<div class="form-group"><label>User</label><select id="settingsTeamMemberUser"></select></div>
<div class="form-group"><label>Role</label><select id="settingsTeamMemberRole"><option value="member">Member</option><option value="admin">Team Admin</option></select></div>
</div>
<div class="form-row"><button class="btn-small btn-primary" id="settingsTeamAddMemberSubmit">Add</button><button class="btn-small" id="settingsTeamCancelMember">Cancel</button></div>
</div>
<button class="btn-small" id="settingsTeamAddMemberBtn" style="margin-top:6px">+ Add Member</button>
</section>
<!-- Team Presets -->
<section class="settings-section">
<h4 style="font-size:13px;margin-bottom:6px">Team Presets</h4>
<div id="settingsTeamPresets"></div>
<div id="settingsTeamAddPreset" style="display:none;margin-top:8px" class="admin-inline-form">
<div class="form-row">
<div class="form-group"><label>Name</label><input type="text" id="settingsTeamPresetName" placeholder="Code Reviewer"></div>
<div class="form-group"><label>Icon</label><input type="text" id="settingsTeamPresetIcon" placeholder="🔍" maxlength="4" style="width:60px"></div>
</div>
<div class="form-group"><label>Description</label><input type="text" id="settingsTeamPresetDesc" placeholder="Reviews code for best practices"></div>
<div class="form-row">
<div class="form-group"><label>Base Model</label><select id="settingsTeamPresetModel"></select></div>
</div>
<div class="form-group"><label>System Prompt</label><textarea id="settingsTeamPresetPrompt" rows="3" placeholder="You are a code reviewer..."></textarea></div>
<div class="form-row">
<div class="form-group"><label>Temperature</label><input type="number" id="settingsTeamPresetTemp" step="0.1" min="0" max="2" placeholder="default"></div>
<div class="form-group"><label>Max Tokens</label><input type="number" id="settingsTeamPresetMaxTokens" placeholder="default"></div>
</div>
<div class="form-row"><button class="btn-small btn-primary" id="settingsTeamCreatePresetBtn">Create</button><button class="btn-small" id="settingsTeamCancelPreset">Cancel</button></div>
</div>
<button class="btn-small" id="settingsTeamAddPresetBtn" style="margin-top:6px">+ New Team Preset</button>
</section>
</div>
</div>
</div> </div>
<div class="modal-footer"><button class="btn-primary" id="settingsSaveBtn">Save Settings</button></div> <div class="modal-footer"><button class="btn-primary" id="settingsSaveBtn">Save Settings</button></div>
</div> </div>
@@ -324,6 +368,7 @@
<button class="admin-tab" data-tab="teams">Teams</button> <button class="admin-tab" data-tab="teams">Teams</button>
<button class="admin-tab" data-tab="settings">Settings</button> <button class="admin-tab" data-tab="settings">Settings</button>
<button class="admin-tab" data-tab="stats">Stats</button> <button class="admin-tab" data-tab="stats">Stats</button>
<button class="admin-tab" data-tab="audit">Audit</button>
</div> </div>
<div class="modal-body"> <div class="modal-body">
<div class="admin-tab-content" id="adminUsersTab"> <div class="admin-tab-content" id="adminUsersTab">
@@ -479,6 +524,26 @@
<button class="btn-primary btn-small" id="adminSaveSettings">Save Settings</button> <button class="btn-primary btn-small" id="adminSaveSettings">Save Settings</button>
</div> </div>
<div class="admin-tab-content" id="adminStatsTab" style="display:none"><div id="adminStats"></div></div> <div class="admin-tab-content" id="adminStatsTab" style="display:none"><div id="adminStats"></div></div>
<div class="admin-tab-content" id="adminAuditTab" style="display:none">
<div class="admin-toolbar" style="margin-bottom:8px">
<select id="auditFilterAction" style="min-width:140px"><option value="">All actions</option></select>
<select id="auditFilterResource" style="min-width:120px">
<option value="">All types</option>
<option value="user">user</option>
<option value="team">team</option>
<option value="preset">preset</option>
<option value="channel">channel</option>
<option value="config">config</option>
</select>
<button class="btn-small" id="auditRefreshBtn">Refresh</button>
</div>
<div id="adminAuditList"></div>
<div class="pagination-row" id="auditPagination" style="display:none;margin-top:8px">
<button class="btn-small" id="auditPrevBtn">← Prev</button>
<span id="auditPageInfo" style="font-size:12px;color:var(--text-3)"></span>
<button class="btn-small" id="auditNextBtn">Next →</button>
</div>
</div>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -292,6 +292,18 @@ const API = {
// ── Admin Teams ───────────────────────── // ── Admin Teams ─────────────────────────
adminListTeams() { return this._get('/api/v1/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 }); }, adminCreateTeam(name, description) { return this._post('/api/v1/admin/teams', { name, description }); },
adminGetTeam(id) { return this._get(`/api/v1/admin/teams/${id}`); }, adminGetTeam(id) { return this._get(`/api/v1/admin/teams/${id}`); },
adminUpdateTeam(id, updates) { return this._put(`/api/v1/admin/teams/${id}`, updates); }, adminUpdateTeam(id, updates) { return this._put(`/api/v1/admin/teams/${id}`, updates); },

View File

@@ -1119,6 +1119,58 @@ function initListeners() {
} catch (e) { UI.toast(e.message, 'error'); } } 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 = '<option value="">Loading...</option>';
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 = '<option value="">Enter user ID or ask sys-admin</option>';
} catch (e) { sel.innerHTML = '<option value="">Error</option>'; }
});
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 // Admin — banner controls
document.getElementById('adminBannerEnabled')?.addEventListener('change', (e) => { document.getElementById('adminBannerEnabled')?.addEventListener('change', (e) => {
document.getElementById('bannerConfigFields').style.display = e.target.checked ? '' : 'none'; 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('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(); }}); 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 // Input
const input = document.getElementById('messageInput'); const input = document.getElementById('messageInput');
input.addEventListener('keydown', (e) => { input.addEventListener('keydown', (e) => {
@@ -1722,6 +1781,36 @@ async function removeTeamMember(teamId, memberId, email) {
} catch (e) { UI.toast(e.message, 'error'); } } 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 ───────────────────────────── // ── Notes Panel ─────────────────────────────
var _editingNoteId = null; var _editingNoteId = null;

View File

@@ -640,6 +640,7 @@ const UI = {
} }
if (tab === 'models') UI.loadUserModels(); if (tab === 'models') UI.loadUserModels();
if (tab === 'appearance') UI.loadAppearanceSettings(); if (tab === 'appearance') UI.loadAppearanceSettings();
if (tab === 'teams') UI.loadTeamsTab();
}, },
// ── Appearance Settings ───────────────── // ── Appearance Settings ─────────────────
@@ -710,10 +711,17 @@ const UI = {
async loadMyTeams() { async loadMyTeams() {
const section = document.getElementById('settingsTeamsSection'); const section = document.getElementById('settingsTeamsSection');
const el = document.getElementById('settingsTeamsList'); const el = document.getElementById('settingsTeamsList');
const tabBtn = document.getElementById('settingsTeamsTabBtn');
if (!section || !el) return; if (!section || !el) return;
try { try {
const resp = await API.listMyTeams(); const resp = await API.listMyTeams();
const teams = resp.data || []; 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) { if (teams.length === 0) {
section.style.display = 'none'; section.style.display = 'none';
return; return;
@@ -729,7 +737,102 @@ const UI = {
<div class="text-muted" style="font-size:11px">${t.member_count} member${t.member_count !== 1 ? 's' : ''}</div> <div class="text-muted" style="font-size:11px">${t.member_count} member${t.member_count !== 1 ? 's' : ''}</div>
</div> </div>
`).join(''); `).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 = '<div class="empty-hint">You are not an admin of any teams.</div>';
return;
}
picker.innerHTML = adminTeams.map(t => `
<div class="team-card" style="cursor:pointer" onclick="UI.openTeamManage('${t.id}', '${esc(t.name)}')">
<div class="team-card-info">
<strong>${esc(t.name)}</strong>
<span class="badge-admin">admin</span>
<span style="margin-left:auto;color:var(--text-3);font-size:12px">Manage →</span>
</div>
${t.description ? `<div class="text-muted" style="font-size:12px">${esc(t.description)}</div>` : ''}
<div class="text-muted" style="font-size:11px">${t.member_count} member${t.member_count !== 1 ? 's' : ''}</div>
</div>
`).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 = '<div class="loading">Loading...</div>';
try {
const resp = await API.teamListMembers(teamId);
const members = resp.data || [];
el.innerHTML = members.map(m => `
<div class="admin-user-row" style="padding:6px 0">
<div class="admin-user-info">
<strong style="font-size:13px">${esc(m.display_name || m.email)}</strong>
<span class="badge-${m.role === 'admin' ? 'admin' : 'user'}" style="font-size:10px">${m.role}</span>
</div>
<div class="admin-user-actions">
<select onchange="settingsUpdateTeamMember('${teamId}','${m.id}',this.value)" class="inline-select">
<option value="member" ${m.role === 'member' ? 'selected' : ''}>Member</option>
<option value="admin" ${m.role === 'admin' ? 'selected' : ''}>Admin</option>
</select>
<button class="btn-delete" onclick="settingsRemoveTeamMember('${teamId}','${m.id}','${esc(m.email)}')" title="Remove">✕</button>
</div>
</div>
`).join('') || '<div class="empty-hint">No members</div>';
} catch (e) { el.innerHTML = `<div class="error-hint">${esc(e.message)}</div>`; }
},
async loadTeamManagePresets(teamId) {
const el = document.getElementById('settingsTeamPresets');
el.innerHTML = '<div class="loading">Loading...</div>';
try {
const resp = await API.teamListPresets(teamId);
const presets = resp.presets || [];
el.innerHTML = presets.map(p => {
const icon = p.icon || '';
return `<div class="admin-preset-row" style="padding:6px 0">
<div class="preset-info">
<strong>${icon ? icon + ' ' : ''}${esc(p.name)}</strong>
<div class="preset-meta" style="font-size:11px">${esc(p.base_model_id)}</div>
</div>
<button class="btn-delete" onclick="settingsDeleteTeamPreset('${teamId}','${p.id}','${esc(p.name)}')" title="Delete">✕</button>
</div>`;
}).join('') || '<div class="empty-hint">No team presets — create one to give your team preconfigured models</div>';
} catch (e) { el.innerHTML = `<div class="error-hint">${esc(e.message)}</div>`; }
},
async loadTeamPresetModelDropdown() {
const sel = document.getElementById('settingsTeamPresetModel');
if (!sel) return;
sel.innerHTML = '<option value="">Select base model...</option>';
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 ──────────────────────────── // ── Providers ────────────────────────────
@@ -778,6 +881,7 @@ const UI = {
if (tab === 'users') await this.loadAdminUsers(); if (tab === 'users') await this.loadAdminUsers();
if (tab === 'stats') await this.loadAdminStats(); if (tab === 'stats') await this.loadAdminStats();
if (tab === 'audit') await this.loadAuditLog();
if (tab === 'providers') await this.loadAdminProviders(); if (tab === 'providers') await this.loadAdminProviders();
if (tab === 'models') await this.loadAdminModels(); if (tab === 'models') await this.loadAdminModels();
if (tab === 'presets') await this.loadAdminPresets(); if (tab === 'presets') await this.loadAdminPresets();
@@ -840,6 +944,85 @@ const UI = {
} catch (e) { el.innerHTML = `<div class="error-hint">${esc(e.message)}</div>`; } } catch (e) { el.innerHTML = `<div class="error-hint">${esc(e.message)}</div>`; }
}, },
_auditPage: 1,
_auditPerPage: 30,
async loadAuditLog(page) {
if (page) UI._auditPage = page;
const el = document.getElementById('adminAuditList');
el.innerHTML = '<div class="loading">Loading...</div>';
// 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 = '<div class="empty-hint">No audit entries found</div>';
} 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 `<div class="audit-row">
<div class="audit-main">
<span class="audit-action">${esc(e.action)}</span>
<span class="audit-actor">${esc(actor)}</span>
<span class="audit-resource">${esc(e.resource_type)}${e.resource_id ? ':' + esc(e.resource_id).slice(0,8) : ''}</span>
</div>
<div class="audit-meta">
${meta ? `<span class="audit-details">${meta}</span>` : ''}
<span class="audit-time">${timeStr}</span>
${e.ip_address ? `<span class="audit-ip">${esc(e.ip_address)}</span>` : ''}
</div>
</div>`;
}).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 = `<div class="error-hint">${esc(e.message)}</div>`; }
},
_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) { async loadAdminProviders(quiet) {
const el = document.getElementById('adminProviderList'); const el = document.getElementById('adminProviderList');
if (!quiet) el.innerHTML = '<div class="loading">Loading...</div>'; if (!quiet) el.innerHTML = '<div class="loading">Loading...</div>';